Browse documentationOpen

byrcsc/laravel-data-sync · 1.x

Quick start.

Scaffold a products sync, map a supplier CSV onto an Eloquent model, run it inline and queued, and read the run history.

This walkthrough takes a supplier CSV into a Product model, runs it both inline and through the queue, and reads the result back out of the run history. It assumes the package is installed and migrated.

1. Scaffold the definition

php artisan make:sync ProductsSync

That writes app/Syncs/ProductsSync.php. The Sync suffix is appended when you leave it off, so make:sync Products generates the same class.

2. Describe the feed

Given products/products-2026-07-31.csv on the incoming disk:

SKU,Description,Price,Currency
DS-1001,Anglepoise desk lamp,89.00,PHP
DS-1002,Mechanical keyboard,149.50,PHP

fill in the source, the format, the model, the mapping, and the match columns:

namespace App\Syncs;

use App\Models\Product;
use ByRcsc\LaravelDataSync\Contracts\Reader as ReaderContract;
use ByRcsc\LaravelDataSync\Definitions\Field;
use ByRcsc\LaravelDataSync\Definitions\Reader;
use ByRcsc\LaravelDataSync\Definitions\Source;
use ByRcsc\LaravelDataSync\Definitions\SyncDefinition;

final class ProductsSync extends SyncDefinition
{
    public function source(): Source
    {
        return Source::disk('incoming')
            ->path('products')
            ->minFileAge(minutes: 2);
    }

    public function format(): ReaderContract
    {
        return Reader::csv();
    }

    public function model(): string
    {
        return Product::class;
    }

    public function fields(): array
    {
        return [
            Field::make('sku', from: 'SKU'),
            Field::make('name', from: 'Description'),
            Field::make('price_cents', from: 'Price')
                ->transform(fn (string $value): int => (int) round((float) $value * 100)),
            Field::make('currency')->default('PHP'),
            Field::make('discontinued_at')->optional(),
        ];
    }

    public function matchOn(): array
    {
        return ['sku'];
    }

    public function rules(): array
    {
        return [
            'sku' => ['required', 'string', 'max:32'],
            'price_cents' => ['required', 'integer', 'min:0'],
        ];
    }
}

Three things are worth noticing.

minFileAge() defers files whose modification time is too recent, which is how you avoid reading a file the supplier is still uploading. Deferred files are counted separately and picked up on a later run.

Field::make('discontinued_at')->optional() says the column may be absent. A field that is neither optional nor defaulted must be present in the file — a missing one is a schema mismatch and the run fails before any row is written, rather than importing half a feed.

matchOn() names the columns that identify a row. Back them with a unique index on products; sync:doctor warns when they are not.

3. Register it

// config/data-sync.php
'syncs' => [
    App\Syncs\ProductsSync::class,
],

The name is derived from the class — ProductsSync becomes products. Override name() on the definition when you want something else.

4. Run it

Start in-process, where you can see everything happen:

php artisan sync:run products --now
Running [products]...
  File run [01JQ8Z3M0K2N4P6R8T0V2X4Y6A] (bulk) ... 2 created, 0 updated, 0 skipped, 0 failed
[products] finished: 1 discovered, 0 skipped, 0 deferred.

Run it again and nothing happens: the checksum is in the ledger, and the file is counted as skipped. Edit the CSV so its contents change — or pass --force — to process it again.

Now try the queued path, which is what sync:run does by default:

php artisan queue:work        # terminal 1
php artisan sync:run products # terminal 2

The command discovers the file, stages it, records a run, and dispatches a job. That job plans the file and fans its chunks out through Bus::batch(), so several workers can share one file. Nothing is written until a worker picks the jobs up.

5. Check before you commit to it

php artisan sync:run products --dry-run

--dry-run validates and counts without writing models, running write hooks, archiving the file, cleaning up the source, or marking the checksum processed. It always runs in-process. The run is still recorded, and sync:status marks it as a dry run.

Treat the counts as a preview rather than a guarantee: dry runs skip prepare(), so a definition that reshapes rows there can report slightly different numbers than the real run.

6. Read the history

php artisan sync:status
php artisan sync:status products --limit=25
php artisan sync:status --failures

The table shows the run ULID, the definition, the status, the write path, the created/updated/skipped/failed counters, and — for runs still in flight — queued batch progress from the batch record.

  Run                          Sync      Status · path        C/U/S/F   Progress
  01JQ8Z3M0K2N4P6R8T0V2X4Y6A   products  completed · bulk     2/0/0/0   2 rows

--failures prints one line per failed row: the row number, the validation errors, and the captured payload. See failures and retries.

7. React to a finished run

namespace App\Listeners;

use ByRcsc\LaravelDataSync\Events\SyncCompleted;
use Illuminate\Contracts\Queue\ShouldQueue;

final class NotifyMerchandising implements ShouldQueue
{
    public function handle(SyncCompleted $event): void
    {
        if ($event->run->rows_failed === 0) {
            return;
        }

        // $event->run->ulid, ->rows_created, ->rows_failed,
        // ->error_summary, ->write_path, ->archive_path
    }
}

Package events are dispatched synchronously inside the job doing the work, so queue the listener, not the event. See events and listeners.