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
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
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
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
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
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
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.