byrcsc/laravel-data-sync · 1.x
Validation and hooks.
Validate rows with Laravel rules, reshape them in prepare(), extend the validator, and use the write hooks that switch a definition to the Eloquent write path.
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.
Preparing a row
/**
* @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 aprepare()that changes a match column changes which rows count as duplicates.- Dry runs skip it.
--dry-runmaps and validates without callingprepare(), 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.
Validating rows
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.
Extending the validator
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.
Write hooks
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.
Order of operations
For one row on the Eloquent path:
- The reader yields raw values.
Fieldmapping appliesfrom,transform(), defaults, and capture.prepare()reshapes the row. (Skipped on dry runs.)- Duplicate resolution marks losing rows as skipped.
rules()andwithValidator()validate. Failures are recorded; the run continues.- The model is resolved with
firstOrNew()and the write mode is applied. beforeSave()runs;falseskips 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.
What to read next
- Matching and writing for write modes and the two paths.
- Failures and retries for what happens to a row that fails validation.
- Events and listeners for run-level follow-up work.