fosseva/laravel-reconciler

Idempotent reference-data reconciliation for Laravel. Migrations manage your schema; Reconciler manages your data.

Maintainers

Package info

github.com/fosseva/laravel-reconciler

pkg:composer/fosseva/laravel-reconciler

Transparency log

Statistics

Installs: 2

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

v1.0.0 2026-08-30 13:48 UTC

This package is auto-updated.

Last update: 2026-08-30 13:58:02 UTC


README

Migrations manage your schema. Reconciler manages durable application data — the reference rows, lookup tables, and system-owned configuration every environment needs, kept idempotent and in sync from local to production.

Install

composer require fosseva/laravel-reconciler

Add a PSR-4 entry to your app's composer.json for wherever you want Reconcilers to live (a package can't register this for you):

"autoload": {
    "psr-4": {
        "Database\\Reconcilers\\": "database/reconcilers/"
    }
}
composer dump-autoload
php artisan vendor:publish --tag=reconciler-config

Writing a Reconciler

php artisan make:reconciler PaymentMethodReconciler
namespace Database\Reconcilers;

use App\Models\PaymentMethod;
use Fosseva\Reconciler\Reconciler;
use Fosseva\Reconciler\ReconcileStrategy;

class PaymentMethodReconciler extends Reconciler
{
    public function model(): string
    {
        return PaymentMethod::class;
    }

    public function uniqueBy(): array
    {
        return ['code'];
    }

    public function strategy(): ReconcileStrategy
    {
        return ReconcileStrategy::AlwaysUpdate;
    }

    public function records(): array
    {
        return [
            ['code' => 'card', 'label' => 'Credit or debit card', 'enabled' => true],
            ['code' => 'bank_transfer', 'label' => 'Bank transfer', 'enabled' => true],
            ['code' => 'cash', 'label' => 'Cash', 'enabled' => false],
        ];
    }
}

uniqueBy() must be backed by a real unique index on the target table — Reconciler issues a select-then-insert, which isn't atomic under concurrent runs; the database constraint is what actually prevents a duplicate row.

Running reconcilers

Run reconciliation from a deploy step, a scheduled command, CI, or any operational command where your app should verify its required data:

php artisan reconcile:run

Update strategy

public function strategy(): ReconcileStrategy
{
    return ReconcileStrategy::AlwaysUpdate; // default: CreateOnly
}
  • CreateOnly (default) — sets fields only when a row is first created, never touches it again. Right for rows a human might hand-edit afterward.
  • AlwaysUpdate — overwrites the row's fields with records() on every run. Right when the Reconciler should be the ongoing source of truth.

Protect specific columns from AlwaysUpdate even though the row itself stays in sync:

protected function ignoreOnUpdate(): array
{
    return ['password']; // still set on first creation, never touched again
}

Side effects beyond columns

records() can only express plain column values. For anything else — assigning a relationship, reporting a generated secret — override afterReconcile():

protected function afterReconcile(Model $model, array $record, bool $wasRecentlyCreated): void
{
    if ($wasRecentlyCreated) {
        $this->report("Created {$model->name}");
        $model->apiToken()->create([
            'name' => 'system',
            'token' => Str::random(64),
        ]);
    }
}

report() defaults to Log::info(), and reconcile:run wires it to console output automatically.

Ordering

Only needed when one Reconciler depends on rows created by another — declare it directly instead of a magic priority number:

public function dependsOn(): array
{
    return [PaymentMethodReconciler::class];
}

discover() topologically sorts the dependency graph, so PaymentMethodReconciler always runs before anything that names it. It throws a clear LogicException if a declared dependency wasn't itself discovered as a Reconciler, or if two reconcilers depend on each other.

Practical examples

Use Reconciler for data your application owns and expects to be stable across environments.

Payment methods

Payment method rows are often referenced by code, reporting, and integrations. They should exist everywhere, and labels or enabled flags may need to change consistently after a deployment.

use App\Models\PaymentMethod;
use Fosseva\Reconciler\Reconciler;
use Fosseva\Reconciler\ReconcileStrategy;

class PaymentMethodReconciler extends Reconciler
{
    public function model(): string
    {
        return PaymentMethod::class;
    }

    public function uniqueBy(): array
    {
        return ['code'];
    }

    public function strategy(): ReconcileStrategy
    {
        return ReconcileStrategy::AlwaysUpdate;
    }

    public function records(): array
    {
        return [
            ['code' => 'card', 'label' => 'Credit or debit card', 'enabled' => true],
            ['code' => 'bank_transfer', 'label' => 'Bank transfer', 'enabled' => true],
            ['code' => 'cash', 'label' => 'Cash', 'enabled' => false],
        ];
    }
}

Integration endpoints

Webhook or partner endpoint records are good reconciliation targets when deployments need the same named endpoints in every environment, while secrets remain locally managed.

use App\Models\IntegrationEndpoint;
use Fosseva\Reconciler\Reconciler;
use Fosseva\Reconciler\ReconcileStrategy;
use Illuminate\Support\Str;

class IntegrationEndpointReconciler extends Reconciler
{
    public function model(): string
    {
        return IntegrationEndpoint::class;
    }

    public function uniqueBy(): array
    {
        return ['provider', 'event'];
    }

    public function strategy(): ReconcileStrategy
    {
        return ReconcileStrategy::AlwaysUpdate;
    }

    protected function ignoreOnUpdate(): array
    {
        return ['secret'];
    }

    public function records(): array
    {
        return [
            [
                'provider' => 'stripe',
                'event' => 'invoice.paid',
                'handler' => 'billing.invoice_paid',
                'secret' => Str::random(40),
                'active' => true,
            ],
            [
                'provider' => 'shiprocket',
                'event' => 'shipment.delivered',
                'handler' => 'shipping.shipment_delivered',
                'secret' => Str::random(40),
                'active' => true,
            ],
        ];
    }
}

Testing your reconcilers

Ship the architecture guarantees Reconciler depends on as tests, without hand-writing them:

use Fosseva\Reconciler\Testing\AssertsReconcilerArchitecture;

class ReconcilerArchitectureTest extends TestCase
{
    use AssertsReconcilerArchitecture;

    public function test_every_reconciler_extends_the_base_class(): void
    {
        $this->assertEveryReconcilerExtendsBaseClass();
    }

    public function test_reconcilers_never_use_fake_or_factories(): void
    {
        $this->assertNoFactoriesInReconcilerPath();
    }

    public function test_every_reconciler_declares_a_unique_key(): void
    {
        $this->assertEveryReconcilerDeclaresUniqueByOnEveryRecord();
    }

    public function test_reconcilers_are_idempotent(): void
    {
        $this->assertReconcilersAreIdempotent();
    }
}

Or in Pest, use(AssertsReconcilerArchitecture::class) in your TestCase and call the same methods from $this inside test(...) closures.

License

MIT.