Domain eventy a eventy Eloquenta w Laravelu

7 min. czytania

Zdarzenia Eloquent created, updated i saved mówią, że zmienił się wiersz. Nie mówią aplikacji, dlaczego. Odnowienie subskrypcji może przedłużyć dostęp, utworzyć fakturę, zapisać płatność i powiadomić klienta. Żadnego z tych faktów nie opisuje uczciwie „subskrypcja została zapisana”.

Różnica ma znaczenie, gdy observer zaczyna wysyłać e-mail, wołać billing i aktualizować analitykę. Dostaje mutowalny model, nie ma nazwy biznesowej i uruchamia się przy korekcie administracyjnej tak samo jak przy odnowieniu. Domain event czyni fakt w czasie przeszłym jawnym. Daje niezależnym konsumentom stabilny payload i oddziela transakcję podejmującą decyzję od pracy, która na nią reaguje.

Zdarzenia Eloquent to hooki trwałości, nie kontrakt integracyjny

Observer jest dobry dla lokalnej trwałości: utrzymania wartości pochodnej albo unieważnienia taniego, wewnętrznego cache'u. Staje się kruchy, gdy koduje politykę, która nie jest prawdziwa dla każdej aktualizacji:

php
<?php

declare(strict_types=1);

namespace App\Observers;

use App\Models\Subscription;
use App\Notifications\SubscriptionRenewedNotification;

final class SubscriptionObserver
{
    public function updated(Subscription $subscription): void
    {
        if ($subscription->wasChanged('renews_at')) {
            $subscription->account->notify(
                new SubscriptionRenewedNotification($subscription),
            );
        }
    }
}

Administrator korygujący renews_at powiadomi klienta. Retry może powiadomić dwa razy. Drugi konsument zmienia cykl życia modelu w przypadkową szynę integracyjną. Znaczenie zmiany znamy w akcji odnowienia, dlatego to ona powinna dispatchować zdarzenie.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Events;

use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;

final class SubscriptionRenewed implements ShouldDispatchAfterCommit
{
    use Dispatchable;

    public function __construct(
        public readonly int $subscriptionId,
        public readonly int $accountId,
        public readonly int $invoiceId,
        public readonly string $renewedAt,
        public readonly string $renewsAt,
    ) {
    }
}

Nazwa w czasie przeszłym mówi, że decyzja już zapadła. Skalarne identyfikatory i trwałe wartości są bezpieczniejsze od całego grafu modeli dla listenera, który może ruszyć znacznie później. ShouldDispatchAfterCommit jest kluczowe: nie pozwala konsumentom obserwować pracy wycofanej przez bazę.

Akcja i transakcja są źródłem prawdy

Jedna akcja pokazuje jedną operację biznesową. Blokuje subskrypcję, aby dwa callbacki płatności nie odnowiły tego samego okresu, zapisuje fakturę i emituje fakt w transakcji. Dodaj unikalny indeks na invoices.payment_reference; sam warunek PHP nie ochroni workerów działających równolegle.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Actions;

use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Invoice;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;

final class RenewSubscription
{
    public function execute(int $subscriptionId, string $paymentReference): void
    {
        DB::transaction(function () use ($subscriptionId, $paymentReference): void {
            $subscription = Subscription::query()
                ->lockForUpdate()
                ->findOrFail($subscriptionId);

            if ($subscription->last_payment_reference === $paymentReference) {
                return;
            }

            $renewedAt = now()->toImmutable();
            $nextRenewal = $subscription->renews_at->greaterThan($renewedAt)
                ? $subscription->renews_at->toImmutable()->addMonthNoOverflow()
                : $renewedAt->addMonthNoOverflow();

            $invoice = Invoice::query()->create([
                'account_id' => $subscription->account_id,
                'subscription_id' => $subscription->id,
                'payment_reference' => $paymentReference,
                'amount_cents' => $subscription->price_cents,
                'issued_at' => $renewedAt,
            ]);

            $subscription->update([
                'last_payment_reference' => $paymentReference,
                'renews_at' => $nextRenewal,
            ]);

            SubscriptionRenewed::dispatch(
                subscriptionId: $subscription->id,
                accountId: $subscription->account_id,
                invoiceId: $invoice->id,
                renewedAt: $renewedAt->toIso8601String(),
                renewsAt: $nextRenewal->toIso8601String(),
            );
        });
    }
}

Zdarzenie jest tutaj, a nie w Subscription::updated: zmiana daty oznacza odnowienie tylko wtedy, gdy ta operacja tak mówi. Transakcja powinna być krótka. Nie wykonuj zewnętrznego HTTP, trzymając blokadę; najpierw zatwierdź decyzję, potem niech listener wykona integrację.

Listenery kolejkowe wymagają idempotencji

Kolejki Laravel dostarczają co najmniej raz. Worker może przekroczyć timeout, mieć retry albo umrzeć po zaakceptowaniu requestu przez API. Jednorazowy dispatch nie chroni przed duplikatem. Daj odbiorcy deterministyczny idempotency key, pobierz tylko potrzebne dane i rzuć wyjątek przy błędzie, aby kolejka mogła ponowić próbę.

php
<?php

declare(strict_types=1);

namespace App\Domain\Subscriptions\Listeners;

use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Invoice;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Http\Client\Factory as HttpFactory;

final class SyncRenewalToBilling implements ShouldQueue
{
    public int $tries = 3;

    public int $timeout = 30;

    /** @var list<int> */
    public array $backoff = [5, 30, 120];

    public function __construct(private readonly HttpFactory $http)
    {
    }

    public function handle(SubscriptionRenewed $event): void
    {
        $invoice = Invoice::query()->findOrFail($event->invoiceId);

        $this->http->acceptJson()
            ->connectTimeout(3)
            ->timeout(10)
            ->withHeader('Idempotency-Key', "subscription-renewal:{$invoice->id}")
            ->post(config('services.billing.url').'/renewals', [
                'account_id' => $event->accountId,
                'invoice_id' => $invoice->id,
                'renewed_at' => $event->renewedAt,
                'renews_at' => $event->renewsAt,
            ])
            ->throw();
    }
}

Laravel domyślnie odkrywa listenery w app/Listeners; listener w app/Domain/... wymaga dodania ścieżki przez withEvents(discover: [...]) w bootstrap/app.php albo rejestracji przez Event::listen().

Ustaw retry_after połączenia kolejki powyżej timeoutu joba, inaczej dwóch workerów może wykonać listener równolegle. Jeżeli API nie obsługuje klucza idempotencji, zapisz stan dostawy z blokadą lub w osobnej tabeli. To nie tworzy niemożliwego dostarczenia „dokładnie raz”, lecz czyni duplikaty świadomie obsługiwanym i widocznym problemem.

Testuj fakt i efekt osobno

Test akcji potwierdza fakt biznesowy oraz stan. Event::fake() nie pozwala, by test domenowy stał się testem integracji HTTP albo kolejki.

php
<?php

declare(strict_types=1);

use App\Domain\Subscriptions\Actions\RenewSubscription;
use App\Domain\Subscriptions\Events\SubscriptionRenewed;
use App\Models\Subscription;
use Illuminate\Support\Facades\Event;

it('records an invoice and emits a renewal fact', function (): void {
    Event::fake([SubscriptionRenewed::class]);
    $subscription = Subscription::factory()->create([
        'renews_at' => now()->subDay(),
        'last_payment_reference' => null,
    ]);

    app(RenewSubscription::class)->execute($subscription->id, 'pay_123');

    expect($subscription->fresh()->last_payment_reference)->toBe('pay_123');

    Event::assertDispatched(SubscriptionRenewed::class, fn (SubscriptionRenewed $event): bool =>
        $event->subscriptionId === $subscription->id
        && $event->accountId === $subscription->account_id,
    );
});

Listener przetestuj osobno przez Http::fake() oraz Http::preventStrayRequests(). Sprawdź URL, payload i nagłówek idempotencji — to granica, za którą odpowiada. Osobny test powinien objąć podwójne otrzymanie tego samego zdarzenia przez weryfikację klucza CRM lub lokalnego rekordu dostawy.

Outbox zamyka inne okno awarii

Dispatch po commicie zapobiega publikacji wycofanej pracy, ale proces może nadal umrzeć po commicie, zanim przekaże zdarzenie do kolejki. Transactional outbox jest właściwy, gdy zgubienie opłaconej faktury, wiadomości do partnera lub eksportu audytowego kosztuje realne pieniądze. Zapisz rekord outboxa w tej samej transakcji, a worker niech przejmie, opublikuje i oznaczy dostawę.

php
<?php

declare(strict_types=1);

namespace App\Domain\Integrations;

use Illuminate\Database\Eloquent\Model;

final class OutboxMessage extends Model
{
    protected $fillable = ['type', 'payload', 'available_at', 'delivered_at'];

    protected function casts(): array
    {
        return [
            'payload' => 'array',
            'available_at' => 'immutable_datetime',
            'delivered_at' => 'immutable_datetime',
        ];
    }
}

Użyj wersjonowanego typu, np. subscriptions.renewed.v1, przejmuj małe paczki przez ->lock("for update skip locked") (MySQL 8+ i PostgreSQL) i monitoruj wiek wiadomości oraz błędne próby. Konsument nadal musi być idempotentny: awaria po publikacji, a przed oznaczeniem dostawy, wciąż wywoła retry.

Kiedy nie używać tego projektu

Nie zmieniaj każdego settera w zdarzenie. Edycja nazwy wyświetlanej nie potrzebuje DisplayNameChanged tylko dlatego, że zmienił się model. Zdarzenie nie powinno też zastępować bezpośredniego wywołania, gdy akcja wymaga synchronicznego wyniku, jak przy autoryzacji płatności. To relacja command/query.

Outbox jest podobnie przesadny dla taniego do odbudowania cache'u, a observer nie zastępuje dwulinijkowej akcji. Używaj zdarzeń modelu dla lokalnej trwałości, bezpośrednich serwisów do synchronicznej orkiestracji i domain eventów, gdy niezależni konsumenci, opóźniona praca, audytowalność albo trwałe słownictwo biznesowe uzasadniają dodatkową granicę.

Awaryjne ścieżki zaprojektuj przed produkcją

Typowy błąd to dispatch pracy kolejkowej przed commitem. Listener ściga się z transakcją i czasem nie znajdzie faktury do synchronizacji. ShouldDispatchAfterCommit usuwa ten race condition dla zdarzeń Laravel; ręcznie dispatchowany job potrzebuje afterCommit() albo połączenia po commicie. sleep w listenerze jedynie spowalnia sporadyczny błąd poprawności.

Nie przekazuj modelu, zakładając, że zdarzenie kolejki jest snapshotem. Serializacja odtwarza go później, więc listener może zobaczyć kolejną zmianę. Dołącz niemutowalne wartości decyzji, nie wkładaj danych wrażliwych do payloadu i odpytywanie bazy zostaw dla celowo aktualnego stanu. Listener nie powinien też kompensować nieudanej transakcji biznesowej. Akcja atomowo tworzy fakturę i stan odnowienia, a listener wykonuje osobną integrację z retry. Jeśli billing ma synchronicznie zatwierdzić dostęp, należy on do workflow komendy z jasnym błędem. Zdarzenia rozgłaszają dokonane fakty; komendy koordynują pracę, która musi udać się teraz.

Powiązane artykuły

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.