Browse documentationOpen

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.

DeclarationColumn 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:

PolicyEffect
UnknownColumnPolicy::IgnoreDefault. Extra columns are ignored; the run records their names in unmapped_columns
UnknownColumnPolicy::StrictAn unmapped column fails the run before any row is written
UnknownColumnPolicy::CaptureExtra 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 Capture is 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.