byrcsc/laravel-data-sync · 1.x
Field mapping.
Map source columns onto model attributes with the Field DSL, handle optional and defaulted columns, transform values, and decide what happens to columns no field maps.
fields() returns a list of Field objects, each mapping one source column to
one model attribute.
use ByRcsc\LaravelDataSync\Definitions\Field;
public function fields(): array
{
return [
Field::make('email'), // email -> email
Field::make('name', from: 'Full Name'), // "Full Name" -> name
Field::make('total', from: 3), // column index 3 -> total
Field::make('active')->transform(fn ($v) => $v === 'Y'),
Field::make('notes')->optional(), // absent column is not an error
Field::make('currency')->default('PHP'), // absent column gets a value
];
}Field::make($attribute, from: $column) names the model attribute first and
the source column second. from defaults to the attribute name, and may be
a string (a header name) or an integer (a zero-based index, for headerless
files).
Required, optional, and defaulted
A field that is neither optional nor defaulted must be present in the file's header. A missing one is a schema mismatch: the run fails before a single row is written, naming the missing columns and listing the header that was actually found. That is deliberate — importing half a feed because a supplier renamed a column is worse than not importing it at all.
| Declaration | Column absent from the file |
|---|---|
Field::make('sku') | The run fails with a schema mismatch |
Field::make('notes')->optional() | The attribute is set to null |
Field::make('currency')->default('PHP') | The attribute is set to PHP |
default() implies optional().
Be precise about what "absent" means. The default applies when the row carries
no entry for that column at all — because the column is missing from the header,
or, for JSON and NDJSON, because that object omits the key. It does not apply
to an empty value: a blank CSV cell is an empty string, and a delimited row
shorter than the header yields null for the trailing columns. Both are values,
and both reach the attribute as-is. Use transform() or a validation rule to
handle them.
Transforming values
Field::make('price_cents', from: 'Price')
->transform(fn (string $value): int => (int) round((float) $value * 100));
Field::make('discontinued_at', from: 'Discontinued')
->transform(fn (?string $value): ?string => $value === '' ? null : $value);The closure receives the raw value the reader produced — always a string or
null — and returns whatever should be written to the attribute. It runs during
mapping, before validation, so rules() sees the transformed value.
Transforms run for every row on every pass, including the planning pass on a
queued run. Keep them pure: no database queries, no HTTP calls, no side effects.
Work that needs the model belongs in beforeSave() or
afterSave(); work that needs the whole row
belongs in prepare().
A transform that throws does not fail the run. The row is recorded as a failure with the exception message and processing continues, which is the same treatment a validation failure gets.
Attributes must exist
Every mapped attribute must be a real column on the model's table, or have a set
mutator (setFooAttribute() or an Attribute accessor with a setter).
Definitions are validated when they resolve, so a typo surfaces as an
InvalidDefinitionException naming the attribute rather than as a silent
no-op. The check is skipped when the table does not exist yet, which keeps
migrate:fresh workable.
Duplicate attribute names in fields() are rejected for the same reason. Two
source columns cannot both write name; combine them in
prepare() instead.
Columns no field maps
Feeds carry columns you do not want. onUnknownColumns() decides what happens
to them:
| Policy | Effect |
|---|---|
UnknownColumnPolicy::Ignore | Default. Extra columns are ignored; the run records their names in unmapped_columns |
UnknownColumnPolicy::Strict | An unmapped column fails the run before any row is written |
UnknownColumnPolicy::Capture | Extra columns are collected into one JSON attribute |
Strict is the right choice for a feed whose schema is contractual and whose
drift you want to hear about immediately.
Capture keeps the data without a schema change:
use ByRcsc\LaravelDataSync\Enums\UnknownColumnPolicy;
public function onUnknownColumns(): UnknownColumnPolicy
{
return UnknownColumnPolicy::Capture;
}
public function captureUnmappedInto(): ?string
{
return 'meta';
}The target attribute must be cast to array, json, object, collection,
encrypted:array, or encrypted:collection; the definition fails validation
otherwise.
Capture overwrites. The captured columns replace whatever the attribute held; they are not merged with existing JSON. A row whose file no longer carries an extra column loses the previously captured value for it.
Even with Ignore, the unmapped column names are recorded on every run, so
sync_runs.unmapped_columns is a cheap way to notice a supplier adding fields.
What mapping produces
For each row the reader yields, mapping produces an array keyed by attribute name, in the order the fields are declared, containing:
- the transformed value, when the column is present;
- the declared default, when the column is absent and one is set;
null, when the column is absent and no default is set;- plus the capture attribute, when
Captureis in force.
That array is what prepare() receives, what rules() validates, and what is
written. It is also what matchOn() reads to build a row's identity — so a
match column must be a mapped field, and is checked as such at validation time.
What to read next
- Validation and hooks for
rules(),prepare(), and the write hooks. - Matching and writing for how the mapped row becomes a database write.
- File formats and readers for how raw values are produced.