›
byrcsc/laravel-data-sync · 1.x
Validate, prepare, skip, or customize model writes for each imported row.
Four extension points sit between a mapped row and the database. In order:
prepare(), rules() with withValidator(), then beforeSave() and
afterSave() on the Eloquent write path.
/**
* @param array<string, mixed> $row
* @return array<string, mixed>
*/
public function prepare(array $row): array
{
$row['name'] = trim($row['first_name'].' '.$row['last_name']);
$row['sku'] = strtoupper($row['sku']);
return $row;
}prepare() receives the mapped row, keyed by model attribute, and returns the
row that will be validated and written. Use it for work that spans more than one
field, which a per-field transform() cannot express.
Two things to know:
prepare() runs before duplicate resolution, so a prepare() that
changes a match column changes which rows count as duplicates.--dry-run maps and validates without calling
prepare(), so a definition that reshapes rows there can report slightly
different counts than the real run. Treat dry-run numbers as a preview.Like transforms, prepare() runs on every row on every pass. Keep it pure.
public function rules(): array
{
return [
'sku' => ['required', 'string', 'max:32'],
'price_cents' => ['required', 'integer', 'min:0'],
'currency' => ['required', 'in:PHP,USD'],
];
}Rules are ordinary Laravel validation rules, applied per row against the mapped attributes, so key them by attribute name, not by source column.
A row that fails validation is recorded in sync_row_failures with its errors
and its payload, counted as failed, and skipped. The run continues. Only
maxErrors() stops a run part way
through.
Validation is per row, so unique rules work but cost a query each, and rules
that reference other rows in the file do not apply. Uniqueness within the file
is onDuplicate()'s job.
use Illuminate\Contracts\Validation\Validator;
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$data = $validator->getData();
if (($data['price_cents'] ?? 0) > 1_000_000 && ($data['approved'] ?? '') !== 'Y') {
$validator->errors()->add('price_cents', 'High-value rows need approval.');
}
});
}withValidator() is called with the validator for each row, before it runs. Use
it for conditional checks that rules() cannot express. It is invoked per row,
so avoid per-call setup work that could be hoisted into the definition's
constructor.
use Illuminate\Database\Eloquent\Model;
/**
* @param array<string, mixed> $row
*/
public function beforeSave(Model $model, array $row): mixed
{
if ($row['status'] === 'archived') {
return false; // skip this row without saving
}
$model->imported_at = now();
return null;
}
/**
* @param array<string, mixed> $row
*/
public function afterSave(Model $model, array $row): void
{
$model->categories()->syncWithoutDetaching(
Category::whereIn('code', explode('|', $row['category_codes']))->pluck('id'),
);
}beforeSave() runs on a model that has already been resolved by matchOn() and
filled with the row, immediately before save(). Returning false skips the
row, it is counted as skipped, not failed. Any other return value, including
null, proceeds.
afterSave() runs on the saved model, inside the chunk's transaction. Work that
must not roll back with the chunk, a webhook, an email, belongs in a
SyncCompleted listener instead.
Declaring either hook changes how every row in the definition is written. The definition switches from a bulk upsert to per-row Eloquent writes: model events fire, observers apply, and throughput drops. See the write path tradeoff before adding one.
Both hooks run inside the chunk transaction, so a throw rolls the chunk back rather than failing the single row. Guard against exceptions you can foresee and let genuinely broken data fail validation earlier.
For one row on the Eloquent path:
Field mapping applies from, transform(), defaults, and capture.prepare() reshapes the row. (Skipped on dry runs.)rules() and withValidator() validate. Failures are recorded; the run
continues.firstOrNew() and the write mode is applied.beforeSave() runs; false skips the row.save() fires model events.afterSave() runs.On the bulk path, steps 6 to 9 collapse into one upsert() per chunk with no
model events.