The IngestConfig Class
The IngestConfig object is the declarative heart of your importer. It allows you to define the entire ETL (Extract, Transform, Load) process in a fluent, readable way.
All configuration happens inside the getConfig() method of your importer class.
Basic Setup
for(string $modelClass)
Required. Initializes the configuration for a specific Eloquent model. This must be the static entry point.
IngestConfig::for(\App\Models\Product::class)
fromSource(SourceType $type, array $options = [])
Required. Defines where the data comes from.
- $type: An enum instance of
LaravelIngest\Enums\SourceType. - $options: An associative array of options required by the specific source handler (e.g.,
path,disk,url).
->fromSource(SourceType::FTP, ['disk' => 'ftp-disk', 'path' => 'import.csv'])
Identity & Duplicates
keyedBy(string|array $sourceColumn)
Defines the "Unique ID" column(s) in your source file (not the database column name). This is used to check if a record already exists.
// The CSV has a column "EAN-Code" which is unique
->keyedBy('EAN-Code')
You can also pass an array for composite keys:
->keyedBy(['store_id', 'sku'])
Synthetic / Unmapped Keys
If a keyedBy field is not registered as a mapping or a relation, it is treated as a synthetic key. The field name itself is used as the model attribute / database column. This is useful when you build a combined key at runtime (e.g. inside beforeRow()):
IngestConfig::for(Product::class)
->map('store_id', 'store_id')
->map('sku', 'sku')
->map('name', 'name')
->beforeRow(function (array &$row) {
$row['composite_key'] = $row['store_id'] . '|' . $row['sku'];
})
->keyedBy('composite_key')
->onDuplicate(DuplicateStrategy::UPDATE);
Precondition: The synthetic column must either be a fillable model attribute or exist as a column in the database. If it does not exist, the import will fail at the database level with a
QueryException.
onDuplicate(DuplicateStrategy $strategy)
Defines behavior when a record with the keyedBy value is found in the database.
DuplicateStrategy::SKIP: (Default) Do nothing. Keep the old record.DuplicateStrategy::UPDATE: Overwrite the database record with new data.DuplicateStrategy::FAIL: Stop processing this row and mark it as failed.DuplicateStrategy::UPDATE_IF_NEWER: Only update if the source data is newer (requirescompareTimestamp()).
->onDuplicate(DuplicateStrategy::UPDATE)
compareTimestamp(string $sourceColumn, string $dbColumn = 'updated_at')
Used with DuplicateStrategy::UPDATE_IF_NEWER. Compares a timestamp from the source data with a database column to determine if the record should be updated.
->onDuplicate(DuplicateStrategy::UPDATE_IF_NEWER)
->compareTimestamp('last_modified', 'updated_at')
Mapping & Transformation
map(string|array $sourceColumn, string $modelAttribute)
A 1:1 copy from source to database. Supports column aliases for files with varying headers.
// Simple mapping
->map('First Name', 'first_name')
// With aliases - first match wins
->map(['email', 'E-Mail', 'user_email'], 'email')
->map(['name', 'full_name', 'Name'], 'name')
mapAndTransform(string|array $sourceColumn, string $modelAttribute, Closure|TransformerInterface|string|array $transformer)
Transforms the value before saving. Also supports column aliases. The transformer can be provided in four forms:
- Closure:
fn($value, array $row) => mixed - TransformerInterface instance:
new NumericTransformer(decimals: 2) - Class-name string (auto-resolved):
DivideByHundredTransformer::class - Array of transformers (applied in sequence):
[new NumericTransformer(), fn($val) => $val * 100]
// Closure
->mapAndTransform('Last Name', 'full_name', function($value, $row) {
return $row['First Name'] . ' ' . $value;
})
// TransformerInterface instance
->mapAndTransform('price_cents', 'price', new NumericTransformer(decimals: 2))
// Class-name string (auto-resolved)
->mapAndTransform('price_cents', 'price', DivideByHundredTransformer::class)
// Array of transformers (applied in sequence)
->mapAndTransform('price_cents', 'price', [
fn($val) => (int) $val,
new DivideByHundredTransformer(),
])
// With aliases
->mapAndTransform(['status', 'Status', 'STATE'], 'is_active', fn($val) => $val === 'active')
See also: Transformation Pipelines
mapAndValidate(string|array $sourceColumn, string $modelAttribute, ValidatorInterface|string|array $validator)
Maps a column and validates it using a custom validator before saving. Supports column aliases. The validator can be provided as a ValidatorInterface instance, a class-name string (auto-resolved), or an array of validators.
// ValidatorInterface instance
->mapAndValidate('email', 'email', new EmailValidator())
// Class-name string (auto-resolved)
->mapAndValidate('email', 'email', EmailValidator::class)
// Array of validators
->mapAndValidate('price', 'price', [MinValueValidator::class, NumericValidator::class])
See also: Validators
mapTransformAndValidate(string|array $sourceColumn, string $modelAttribute, array $transformers, array $validators)
Combines transformation and validation in a single call. Applies all transformers in sequence, then runs all validators. Supports column aliases.
->mapTransformAndValidate(
'price',
'price',
[fn($val) => (float) $val, new DivideByHundredTransformer()],
[MinValueValidator::class, NumericValidator::class]
)
See also: Validators and Transformation Pipelines
mapWhen(string|array $sourceColumn, string $modelAttribute, Closure|ConditionalMappingInterface $condition, Closure|TransformerInterface|string|null $transformer = null, Closure|ValidatorInterface|string|null $validator = null)
Conditionally applies a mapping only when the given condition evaluates to true for the current row. Supports column aliases, optional transformation, and optional validation.
The condition can be a Closure receiving the full row array, or a class implementing ConditionalMappingInterface.
// Conditional mapping with a closure
->mapWhen('status', 'is_active', fn($row) => $row['type'] === 'user', fn($val) => $val === 'active')
// Conditional mapping with a transformer and validator
->mapWhen(
'price',
'price',
fn($row) => $row['type'] === 'premium',
new NumericTransformer(decimals: 2),
MinValueValidator::class
)
// Using a ConditionalMappingInterface class
->mapWhen('status', 'order_status', new OrderStatusMapping())
See also: Conditional Mappings
nest(string $sourceColumn, Closure $callback)
Maps nested data structures (e.g., JSON objects) into related models. The callback receives a NestedIngestConfig instance to define mappings for the nested fields.
->nest('address', function(NestedIngestConfig $config) {
$config
->map('street', 'street_address')
->map('city', 'city')
->map('zip', 'postal_code')
->keyedBy('zip');
})
See also: Nested Mappings
relate(string $sourceColumn, string $relationName, string $relatedModel, string $relatedKey, bool $createIfMissing = false)
Automatically resolves BelongsTo relationships.
- Takes the value from
$sourceColumn. - Searches
$relatedModelwhere$relatedKeymatches that value. - If found, assigns the ID to the foreign key of
$relationName. - If
createIfMissingistrueand no match is found, creates the related record automatically.
relateMany(string $sourceField, string $relationName, string $relatedModel, string $relatedKey = 'id', string $separator = ',')
Synchronizes Many-to-Many relationships from a delimited list in your source data. Perfect for tags, categories, or any pivot table relationship.
Parameters:
- $sourceField: Column name in your source file containing the delimited values
- $relationName: Name of the BelongsToMany relationship in your model
- $relatedModel: The related Eloquent model class
- $relatedKey: Attribute to search for in the related model (default: 'id')
- $separator: Character used to split values (default: ',')
Behavior:
- Duplicates in the source list are automatically handled
- Non-existing related records will cause the row to fail (unless you handle them in
beforeRow()) - The entire pivot table for the relationship is synced (existing relationships not in the list will be removed)
validateWithModelRules()
Merges validation rules defined in the target model's static getRules() method. Useful for DRY (Don't Repeat Yourself). Rules from validate() take precedence over model rules.
// In Product.php
public static function getRules(): array
{
return [
'sku' => 'required|string',
'name' => 'required|min:3',
];
}
// In Config
->validateWithModelRules()
->validate(['price' => 'required|numeric']) // Additional rules
expectSchema(array $schema)
Validates the structure of the source data before processing. Define expected columns with their types and constraints. If the source schema does not match, the import fails early with a clear error.
->expectSchema([
'sku' => ['type' => 'string', 'required' => true],
'price' => ['type' => 'numeric', 'required' => true, 'nullable' => false],
'description' => ['type' => 'string', 'nullable' => true],
])
See also: Schema Validation
Hooks
beforeRow(callable $callback)
Executed before validation. Allows you to modify the raw data array by reference. Perfect for cleaning up messy data globally.
Note: Closures passed to
beforeRoware automatically wrapped in aSerializableClosurefor serialization safety. This ensures the config can be cached or queued without losing the callback logic.
->beforeRow(function(array &$data) {
// Remove invisible characters from all keys
$data = array_combine(
array_map('trim', array_keys($data)),
$data
);
})
afterRow(callable $callback)
Executed after the model has been successfully saved.
- $model: The saved Eloquent model.
- $row: The original raw data.
Note: Closures passed to
afterRoware automatically wrapped in aSerializableClosurefor serialization safety. This ensures the config can be cached or queued without losing the callback logic.
->afterRow(function(Product $product, array $row) {
// Sync tags or trigger side effects
$product->search_index_updated_at = now();
$product->saveQuietly();
})
See also: Import Events
Processing Options
setChunkSize(int $size)
Determines how many rows are processed per background job. Default: 100.
- Increase for simple inserts to reduce queue overhead.
- Decrease for memory-heavy operations (e.g., image processing in
afterRow).
atomic()
Wraps each chunk in a Database Transaction. If one row in the chunk fails, all rows in that chunk are rolled back.
- Default: Disabled (Rows are committed individually).
setDisk(string $disk)
Overrides the default filesystem disk (from config/ingest.php) for this specific importer.
->setDisk('s3_private_bucket')
strictHeaders(bool $strict = true)
Enables strict header validation. When enabled, the import will fail immediately if any mapped source column is missing from the file headers. By default, only the keyedBy column is validated.
->strictHeaders()
->map('email', 'email') // Must exist in source file
->map('name', 'name') // Must exist in source file
Tracing & Debugging
withTracing()
Enables full tracing for the import. This records detailed logs for both mappings and transformations, making it easier to debug complex imports.
->withTracing()
traceTransformations()
Enables tracing for transformations only. Records how each value is transformed during the import process.
->traceTransformations()
traceMappings()
Enables tracing for mappings only. Records how source columns are mapped to model attributes.
->traceMappings()
See also: Debugging & Tracing
Event Handling
withEventHandler(ImportEventHandlerInterface $handler)
Registers a custom event handler to hook into the import lifecycle. The handler must be an instance of ImportEventHandlerInterface.
->withEventHandler(new SendSlackNotificationHandler())
See also: Import Events
Dynamic Model Resolution
resolveModelUsing(callable $callback)
Allows you to dynamically determine which Eloquent model to use based on the row data. This is useful when importing heterogeneous data into different tables.
- Callback Signature:
fn(array $rowData): string - Returns: A fully qualified model class name.
use App\Models\{User, AdminUser, Customer};
IngestConfig::for(User::class)
->resolveModelUsing(function(array $row) {
return match($row['user_type'] ?? 'user') {
'admin' => AdminUser::class,
'customer' => Customer::class,
default => User::class,
};
})
->map('email', 'email')
->map('name', 'name');
Note: The base model class passed to
IngestConfig::for()is used as a fallback if no resolver is set.
Transaction Modes
transactionMode(TransactionMode $mode)
Fine-grained control over database transaction behavior.
TransactionMode::NONE: No transactions (default). Each row is committed individually.TransactionMode::CHUNK: Wraps each chunk in a transaction. Same as callingatomic().TransactionMode::ROW: Wraps each individual row in its own transaction.
use LaravelIngest\Enums\TransactionMode;
->transactionMode(TransactionMode::ROW)
Reusable Mappings
When multiple importers share the same field mappings (e.g., products appear in both orders and refunds), define reusable mapping classes that implement MappingInterface.
Creating a Mapping Class
Create mapping classes in your application (e.g., app/Ingest/Mappings/):
// app/Ingest/Mappings/ProductMapping.php
use LaravelIngest\Contracts\HasMappings;
use LaravelIngest\Contracts\MappingInterface;
use LaravelIngest\Contracts\NestedMappingInterface;
use LaravelIngest\IngestConfig;
use LaravelIngest\NestedIngestConfig;
use LaravelIngest\Transformers\NumericTransformer;
class ProductMapping implements MappingInterface, NestedMappingInterface
{
public function apply(IngestConfig $config, string $prefix = ''): IngestConfig
{
return $this->applyMappings($config, $prefix);
}
public function applyNested(NestedIngestConfig $config, string $prefix = ''): NestedIngestConfig
{
return $this->applyMappings($config, $prefix);
}
private function applyMappings(HasMappings $config, string $prefix = ''): HasMappings
{
$prefix = $prefix !== '' ? "{$prefix}_" : '';
return $config
->map("{$prefix}product_id", 'product_id')
->map("{$prefix}product_name", 'name')
->mapAndTransform(
"{$prefix}price_cents",
'price',
new NumericTransformer(decimals: 2)
)
->map("{$prefix}sku", 'sku');
}
}
Using Mappings in Importers
class OrderImporter implements IngestDefinition
{
public function getConfig(): IngestConfig
{
return IngestConfig::for(Order::class)
->fromSource(SourceType::UPLOAD)
->map('order_id', 'id')
->map('customer_email', 'customer_email')
->applyMapping(new ProductMapping(), 'line_item'); // Prefix: line_item_product_id
}
}
class RefundImporter implements IngestDefinition
{
public function getConfig(): IngestConfig
{
return IngestConfig::for(Refund::class)
->fromSource(SourceType::UPLOAD)
->map('refund_id', 'id')
->applyMapping(new ProductMapping()); // No prefix needed
}
}
Using Mappings in Nested Configs
Reusable mappings can also be used inside nest() blocks to keep nested data structures DRY. The mapping class must implement NestedMappingInterface in addition to MappingInterface.
class OrderImporter implements IngestDefinition
{
public function getConfig(): IngestConfig
{
return IngestConfig::for(Order::class)
->fromSource(SourceType::UPLOAD)
->map('order_id', 'id')
->nest('line_items', function (NestedIngestConfig $nested) {
$nested->applyMapping(new ProductMapping(), 'item');
});
}
}
Note: Mapping classes that only implement
MappingInterface(notNestedMappingInterface) are silently ignored when used inside anest()block. This prevents existing mappings from breaking when reused in nested contexts.
Configurable Mappings
Add fluent configuration methods for flexibility:
// app/Ingest/Mappings/ProductMapping.php
class ProductMapping implements MappingInterface
{
private bool $includeSku = true;
private ?int $priceDecimals = 2;
public function apply(IngestConfig $config, string $prefix = ''): IngestConfig
{
$prefix = $prefix !== '' ? "{$prefix}_" : '';
$config
->map("{$prefix}product_id", 'product_id')
->map("{$prefix}product_name", 'name')
->mapAndTransform(
"{$prefix}price_cents",
'price',
new NumericTransformer(decimals: $this->priceDecimals)
);
if ($this->includeSku) {
$config->map("{$prefix}sku", 'sku');
}
return $config;
}
public function withSku(bool $include = true): self
{
$this->includeSku = $include;
return $this;
}
public function withPriceDecimals(int $decimals): self
{
$this->priceDecimals = $decimals;
return $this;
}
}
Usage with configuration:
IngestConfig::for(Order::class)
->applyMapping(
(new ProductMapping())->withSku(false)->withPriceDecimals(0),
'item'
);
Benefits
- DRY: Define product mappings once, reuse everywhere
- Testability: Unit test mapping logic in isolation
- Consistency: Same transformation logic across all importers
- Flexibility: Configure behavior per importer via fluent methods
Composite Keys
keyedBy() accepts either a single string or an array of strings for composite keys:
// Single column
->keyedBy('sku')
// Composite key
->keyedBy(['store_id', 'sku'])
Synthetic / Unmapped Keys
If a keyedBy field is not registered as a mapping or a relation, it is treated as a synthetic key. The field name itself is used as the model attribute / database column. This is useful when you build a combined key at runtime (e.g. inside beforeRow()):
IngestConfig::for(Product::class)
->map('store_id', 'store_id')
->map('sku', 'sku')
->map('name', 'name')
->beforeRow(function (array &$row) {
$row['composite_key'] = $row['store_id'] . '|' . $row['sku'];
})
->keyedBy('composite_key')
->onDuplicate(DuplicateStrategy::UPDATE);
Precondition: The synthetic column must either be a fillable model attribute or exist as a column in the database. If it does not exist, the import will fail at the database level with a
QueryException.