Documentation for larsasub v0.38.2.
On an older version? Your release notes ship with the package (CHANGELOG.md).
Events
Every supported lifecycle change LarsaSub makes is announced with a Laravel event. Listening to one is the supported way to extend the package without modifying it: provision a workspace on activation, push an invoice into your accounting system, tell a customer their VAT number was refused.
Three omissions are deliberate and documented per event below: a plan change
fires Changed instead of Activated, a subscription deactivated before its
start day gets its marker without a Started event, and the
scheduler-only events never fire on a host without a
running scheduler.
- Listening
- Subscription events
- Charge events
- Single payment events
- Provider payment events
- Cost line events
- Credit events
- Mandate events
- Invoice events
- Connect and terms events
- Settlement events
- Billable events
- Dunning events
- Scheduler-only events
- Adding a property to a queued event
Listening
Events live in one sub-namespace per group: Events\BillableSubscription,
Events\Subscription (the plan), Events\RecurringCharge,
Events\SinglePayment, Events\ProviderPayment,
Events\SubscriptionCostLine, Events\Credit, Events\Mandates,
Events\Invoice, Events\Connect, Events\Terms, Events\Settlement,
Events\Dunning, Events\Billable. Register a listener the usual way:
// app/Providers/EventServiceProvider.php
protected $listen = [
\PetersDevelopment\LarsaSub\Events\BillableSubscription\BillableSubscriptionActivatedEvent::class => [
\App\Listeners\ProvisionWorkspace::class,
],
];
An event's public properties are its payload, and they are listed in the
Payload column of each table below. The class names and their constructor
arguments are public API (PUBLIC-API). Every event uses
SerializesModels, so a queued listener re-reads the models it carries when it
runs.
Replacing a bundled notification
Most events have a bundled listener that sends a mail. To replace one of those
notification listeners (the Send*Listener classes) rather than add to it,
disable it and register your own:
'notifications' => [
'disabled_listeners' => [
\PetersDevelopment\LarsaSub\Listeners\BillableSubscription\SendSubscriptionActivatedToUserListener::class,
],
],
The key does nothing for any other listener — the package warns at boot and in
larsasub:doctor when it names one. The automatic settlement of a double-paid
chargeback follow-up, for instance, is switched off with
dunning.double_paid = signal, not here. To silence a mail rather than replace
it, use its toggle; see Notifications.
Subscription events
| Event | Payload | Dispatched when |
|---|---|---|
BillableSubscriptionActivatedEvent |
billableSubscription, recurringCharge (nullable), source (?Enums\ActivationSource) |
Every activation except a plan change: first payment succeeded, free plan activated at creation, deferred-mandate creation, or a subscription you create with activated: true (an import; source is null unless you pass one). source is also stored in activation_source; recurringCharge is non-null only when a charge triggered the activation. Dispatched after the activating transaction commits |
BillableSubscriptionChangedEvent |
previousSubscription, newSubscription, creditAmount (float) |
The plan changed. The change-created subscription stores activation_source = change and fires no Activated event. creditAmount is what the change added to the wallet: the unused part of the old plan, prorated, plus any credit the old subscription still carried |
BillableSubscriptionStartedEvent |
billableSubscription, source (?Enums\ActivationSource) |
The upcoming→active clock transition: once, when an activated subscription's future start date arrives (marker started_processed_at). From larsasub:detect-started-subscriptions; at most once, and it can arrive a day late |
BillableSubscriptionCancelledEvent |
billableSubscription, cancellation (array) |
The subscriber cancelled. cancellation is the array cancelSubscription() returns (end_date, remaining_charges, …); it holds ended_before_start: true when an upcoming subscription was cancelled |
BillableSubscriptionEndedEvent |
billableSubscription |
The end date was reached — or an operator used terminateSubscription() |
BillableSubscriptionRenewalAnnouncedEvent |
billableSubscription, announcement (array: end_date, notice_free_deadline, cancellation_notice_period, renewal_cancellation_notice_period) |
A fixed term with automatic renewal is two weeks from its notice-free cancellation deadline — the last day it can be cancelled before it renews |
BillableSubscriptionRenewedEvent |
billableSubscription, renewal (array: previous_end_date, previous_duration, new_duration, new_cancellation_notice_period) |
Automatic renewal converted the fixed term to a continuous subscription, in place, with the renewal notice period |
SubscriptionVatRateChangedEvent (Events\Subscription) |
plan (Subscription), previousRate, newRate (?float, null = the config rate), changedAt (?CarbonImmutable) |
A plan's own vat_rate changed through the model, after the commit. Every running subscription without a rate of its own follows it from its next charge. Not dispatched for a query-builder update or a change of invoicing.vat_rate. The bundled mail is notifications.user.subscription_vat_rate_changed |
The invariant to build on: a new subscription lifecycle begins with exactly
one of BillableSubscriptionActivatedEvent or BillableSubscriptionChangedEvent.
Listen to both if you provision on activation.
BillableSubscriptionStartedEvent has two deliberate silences. It never fires for
subscriptions that were never upcoming — Activated already covered that moment —
and never for subscriptions deactivated before their start day
(CanceledBeforeStart, terminate-on-upcoming); those get the marker without an
event. For a short fixed term it can arrive after the Ended event.
Cancelling an upcoming subscription dispatches the Cancelled event and deliberately not the Ended one: nothing ended that never ran, so grace and dunning listeners on the ended event stay quiet.
Charge events
A RecurringCharge is any charge the package collects: a subscription period,
a standalone charge, a
chargeback follow-up, or the €0,01 mandate verification. Every event here
carries recurringCharge and billableSubscription (null for a charge without
a subscription).
| Event | Payload (besides the two above) | Dispatched when |
|---|---|---|
RecurringChargeNotifiedEvent |
— | The advance notice for a charge flagged notify is due (larsasub:notify-charges); the bundled announcement mail listens to it |
RecurringChargeChargedEvent |
— | The charge was submitted to the provider |
RecurringChargePaidEvent |
firstPayment (bool) |
Payment confirmed. firstPayment marks the collection that activated the subscription. The invoice (if any) already exists by then. Also fired when the package settles a charge itself — wallet credit covers it in full, or nothing is left to collect when it is executed — which fires no RecurringChargeChargedEvent. A €0 first charge is marked paid at creation and fires neither |
RecurringChargeFailedEvent |
firstPayment (bool) |
The provider rejected it |
RecurringChargeCanceledEvent |
firstPayment (bool) |
The payment was cancelled |
RecurringChargeExpiredEvent |
firstPayment (bool) |
The payment link expired |
RecurringChargeChargebackEvent |
chargeback (Support\PaymentChargeback) |
Per chargeback, once per id |
RecurringChargeChargebackReversedEvent |
chargeback (Support\PaymentChargeback, with reversedAt filled) |
Per chargeback the provider reversed, once per id |
RecurringChargeRefundedEvent |
refund (Support\PaymentRefund) |
Per completed refund |
RecurringChargeFollowUpCreatedEvent |
original, chargeback; recurringCharge is the follow-up |
A chargeback became a follow-up charge. After commit, once per chargeback |
RecurringChargeFollowUpWithdrawnEvent |
original (nullable), chargeback |
The bank reversed the chargeback before the follow-up was paid; the follow-up is soft-deleted. After commit, once per follow-up. Fires instead of the failed/expired/canceled event when a payment under way ends that way after the reversal |
RecurringChargeFollowUpDoublePaidEvent |
original (nullable), chargeback, amount, settleOn, action |
The bank reversed the chargeback after the follow-up was paid. See below. After commit, once per follow-up |
A mandate verification fires RecurringChargePaidEvent like any charge — check
$event->recurringCharge->mandate_setup — but when it fails, expires or is
cancelled it fires MandateSetupFailedEvent instead of
the failed/expired/canceled events.
Double payment. On RecurringChargeFollowUpDoublePaidEvent, amount is
what was paid twice when it was found (decimal string), settleOn is where the
money goes back (original or follow_up), and action is the
dunning.double_paid value at that moment (null: the listener reads the
config). Handle the nulls: amount is null when the follow-up's history is
unreadable; settleOn is null then, when amount is 0.00 (nothing left to
give back), and when the original's refund history is unreadable. The
package's own listener settles it per action — refund, credit, or
signal (flag the follow-up for an operator; the default) — and never gives
back more than is still paid twice when it runs.
RecurringChargeChargebackReversedEvent leaves the charge's status and payable
flag untouched: the original payment is simply paid again. A chargeback that is
already reversed on first sight fires the chargeback event first and this one
straight after. Its follow-up, when there is one, is withdrawn, marked or
treated as paid twice before this event is dispatched, so a listener on it
already sees that state; the two follow-up events themselves are delivered
after commit, so they arrive after this one.
Order within one chargeback delivery. When wallet credit covers a new
follow-up in full, it is settled on the spot, so RecurringChargePaidEvent for
the follow-up fires before RecurringChargeChargebackEvent for the
original. A listener that extends access by a period on every paid event sees
the same period paid twice: skip a charge whose isChargebackFollowUp() is
true, or one whose hasStandingChargeback() is true.
The refund and chargeback value objects survive SerializesModels, so a queued
listener sees the refund the event was fired for — not the snapshot at
execution time. Key your idempotency on $refund->id / $chargeback->id, and
check isBookable() before booking an amount.
Single payment events
Every event here carries singlePayment.
| Event | Payload (besides singlePayment) |
Dispatched when |
|---|---|---|
SinglePaymentPaidEvent |
— | Payment confirmed |
SinglePaymentFailedEvent |
— | The provider rejected it |
SinglePaymentCanceledEvent |
— | Cancelled |
SinglePaymentExpiredEvent |
— | Expired |
SinglePaymentChargebackEvent |
chargeback (Support\PaymentChargeback) |
Per chargeback |
SinglePaymentChargebackReversedEvent |
chargeback (Support\PaymentChargeback) |
Per reversed chargeback |
SinglePaymentRefundedEvent |
refund (Support\PaymentRefund) |
Per completed refund |
Refunds and chargebacks are delivered per occurrence, keyed on the provider's
id — never book the payment total. Attached IsPayable entities get their hooks
called alongside these — see
Single payments.
Provider payment events
Shared across RecurringCharge and SinglePayment: payment is either.
| Event | Payload | Dispatched when |
|---|---|---|
RefundRequestedEvent |
payment, request (Support\RefundRequest) |
RefundService::refund() asked the provider for a refund, or adopted one that already carried the key — after commit, at least once. Not the completion: that is RecurringChargeRefundedEvent / SinglePaymentRefundedEvent |
PaymentNeedsAttentionEvent |
payment, message (string) |
A charge or single payment was flagged needs_attention for an operator (setNeedsAttention()), whatever the reason — or Mollie reported on one after the tenant's Connect account was disconnected (CONNECT_DISCONNECTED_MARKER; once per record, never per payment flagged by the disconnect itself) |
PaymentNeedsAttentionEvent is the alarm for "no usable mandate", "tenant
context unavailable" and provider rejections at execution time — the money
that will not collect itself. The same event fires for every other flag: a
claim the reconciliation parked, a failed mandate registration, a double
payment under dunning.double_paid = signal, a credit note that could not be
created. $event->message says which. Its bundled company mail is
notifications.company.payment_needs_attention.
Cost line events
| Event | Payload | Dispatched when |
|---|---|---|
SubscriptionCostChangedEvent |
billableSubscription, changes (Support\CostLineChangeSet) |
Once per act: one reconcileLines() call, one batch() scope, or one standalone mutation. changes holds every change plus the settled billing-period total |
SubscriptionCostLineAddedEvent |
billableSubscription, costLine, monthlyTotal (float) |
SubscriptionCostService::addLine(), and per added line inside a reconcile |
SubscriptionCostLineChangedEvent |
billableSubscription, costLine, monthlyTotal (float) |
SubscriptionCostService::changeLineAmount(), and per repriced line inside a reconcile |
SubscriptionCostLineEndedEvent |
billableSubscription, costLine, monthlyTotal (float) |
SubscriptionCostService::endLine(), and per removed line inside a reconcile |
Notify from SubscriptionCostChangedEvent, not from the per-line three.
The per-line events fire while a set is still being assembled — reconciling
three modules fires three of them — and each carries the billing-period total
at that moment, so two of those three name an amount the subscriber will
never be charged. They exist for audit and for hosts that track individual
mutations. The bundled cost-change mail listens to the batched event for the
same reason.
public function handle(SubscriptionCostChangedEvent $event): void
{
// Skip sets that are a subscription's opening composition, or were made
// inside SubscriptionCostService::silently() — see Cost lines.
if (! $event->changes->shouldAnnounce()) {
return;
}
foreach ($event->changes->announceable() as $change) {
// $change->type (CostLineChangeType), ->description, ->monthlyAmount,
// ->previousMonthlyAmount, ->effectiveOn
}
$total = $event->changes->monthlyTotal; // as of ->effectiveOn
}
monthlyAmountis listed,monthlyTotalis debited. Three things move them apart: cost lines, VAT (invoicing.prices_include_vat = falseadds it to every listed amount; a zero-rated customer on inclusive prices has it stripped out), and an introduction period, where the charge bills the introduction price.
->vatNoticesays which VAT case applies: anEnums\CollectionNotice(gross,reverse-charge,net) ornull— andnullis what a default install always yields, so handle it explicitly; amatchwithout anullarm throws on the first cost-line change in production.->vatRatecarries the rate, for thegrosscase only.->introMonthlyTotalis what is collected while an introduction period runs;monthlyTotalis then the amount that follows it.- Both totals are per BILLING PERIOD, not per month: on a yearly plan
->monthlyTotalis the yearly debit. The names are frozen wire format (the set rides a queued payload) — read->intervalMonths(1 = monthly) and caption the amount with that, never with the word "month". The per-change->monthlyAmountfields are unaffected: a cost line's amount stays monthly whatever the plan's interval is.All of these are frozen with the total rather than resolved when you render — a customer editing their country between the mutation and your queued job would otherwise get a label describing a number it does not describe. If you print both figures, say which is which, as the bundled notice does.
Credit events
| Event | Payload | Dispatched when |
|---|---|---|
CreditAddedEvent |
billableCredit, transaction |
Credit was added (CreditService::addCredit(), and every path that returns credit) |
CreditDeductedEvent |
billableCredit, transaction |
Credit was deducted — applied to a charge, or taken off through CreditService::deductCredit() |
CreditExpiringEvent |
billableCredit, transaction |
An addition with an unspent remainder expires within scheduling.notify_credit_expiration_days_before days (larsasub:notify-expiring-credits); once per addition |
CreditExpiredEvent |
billableCredit, originalTransaction, expiredAmount (float) |
Credit expired (larsasub:process-expired-credits) |
Mandate events
mandate and replacement are typed Models\BaseModel (provider-neutral):
narrow with instanceof ProviderMandateModel before reading mandate fields.
| Event | Payload | Dispatched when |
|---|---|---|
MandateMarkedUsableEvent |
mandate |
A mandate became usable (setup confirmed) — also the trigger for the user's "payment method added" mail |
MandateMarkedUnusableEvent |
mandate, partOfRemoval (bool) |
A mandate was revoked or invalidated — by the provider (the weekly validation, a revocation at the bank) or as the in-transaction step of every removeMandate(). partOfRemoval tells the two apart (declaration-defaulted false for payloads queued before it existed): a genuine invalidation triggers the user's proactive "payment method no longer usable" mail; the removal step is skipped there because MandateRemovedEvent follows and announces it |
MandateRemovedEvent |
mandate (soft-deleted), replacement (nullable) |
A mandate was removed through MandateService::removeMandate(): reassignment committed, provider revoke attempted, local row soft-deleted. replacement is the mandate future collections were moved to (null when nothing was using it). Dispatched after the whole operation, through the after-commit hook — a host wrapping the call in its own transaction never sees it before the rollback window closed. A provider-side invalidation alone never fires this event |
MandateSetupFailedEvent |
charge, providerStatus (failed, expired or canceled) |
The €0,01 setup checkout ended in a terminal provider state |
MandateRegistrationFailedEvent |
charge, billable, billableSubscription, exceptionClass, exceptionMessage |
A first payment was paid and the mandate registration behind it threw: money in, subscription active, and nothing to collect the next period with. billableSubscription is null for a €0,01 setup charge. The exception itself deliberately does not travel, so a ShouldQueue listener cannot break on the payload. Every property is nullable: a late webhook can land on a hard-deleted billable |
MandateRegistrationFailedEvent does not replace anything — the generic
PaymentNeedsAttentionEvent still fires for the same charge (setNeedsAttention()
dispatches it), together with its company mail. This event exists because that
one fires for every attention reason there is, so reacting specifically to a
missing mandate would otherwise mean string-matching attention_message. Its own
company notification (notifications.company.mandate_registration_failed,
default on) names the consequence rather than the symptom; hosts that want a
single mail turn the generic key off.
Paid is a condition, not a description. The mandate step also runs for a
payment that is not terminal yet — a card under review, a PayPal
authorization, a bank transfer — and it can fail there on details the provider
has not filled in. Nothing has been collected in that case, so this event does
not fire and the generic PaymentNeedsAttentionEvent (which always does) is
the signal for it. If the payment later succeeds and the mandate step fails
again, that is when this event arrives.
MandateSetupFailedEvent replaces the generic charge failed/expired/canceled
events for setup charges, so an abandoned verification mails nobody. Its
pull-side counterpart is $owner->mandateSetupStatus() — see
Payment methods and mandates.
Invoice events
| Event | Payload | Dispatched when |
|---|---|---|
InvoiceGeneratedEvent |
invoice |
An invoice was created |
CreditNoteGeneratedEvent |
creditNote |
A credit note was created |
Use these to push documents into an accounting system. Invoicing is off by
default (invoicing.enabled); with it off the package generates no documents
on its own, so neither event fires. See Invoicing.
Connect and terms events
The Connect events carry account, the tenant's MollieConnectedAccount.
| Event | Payload | Dispatched when |
|---|---|---|
MollieConnectionLostEvent |
account, reason (string), outcomeUnknown (nullable int) |
A tenant's OAuth connection died on an auth rejection — also how a revocation in Mollie's own dashboard arrives. outcomeUnknown is how many payments still under way it flagged (null on a payload queued before the property existed) |
MollieDisconnectRequestedEvent |
account, openPayments (int) |
A tenant asked to disconnect and the account now drains, with openPayments still under way. When nothing was under way, MollieDisconnectedEvent follows at once |
MollieDisconnectCancelledEvent |
account |
A draining disconnect was called off — cancelDisconnect(), or the tenant connecting the same organization again |
MollieDisconnectedEvent |
account, drained (bool), outcomeUnknown (int) |
The connection was removed on purpose: drained (drained true), at the drain deadline, or disconnectNow(). outcomeUnknown payments were still under way and are flagged |
MollieProfileChangedEvent |
account, previousProfileId, profileId |
A tenant selected another Mollie website profile — every payment from here on carries its brand and its enabled payment methods. Not fired for the package's automatic pick on a fresh connect |
TermsAcceptedEvent |
termsAcceptance |
An acceptance was recorded (TermsAcceptanceService::record()), one event per record: the bundled subscribe flow records the terms and, with terms.withdrawal_consent on, the withdrawal consent as a second record (context tells them apart). Your own record() call fires it too |
A drain finishes in the last pass of larsasub:execute-charges, so the
MollieDisconnectedEvent of a drained or deadline-reached disconnect needs the
scheduler — or your own MollieConnectService::finalizeDisconnects() call on a
headless host without one. See
Disconnecting a tenant.
Settlement events
| Event | Payload | Dispatched when |
|---|---|---|
SettlementRecordedEvent |
settlement |
A Mollie payout became known locally for the first time, whatever its status |
SettlementStatusChangedEvent |
settlement, previousStatus |
A known payout changed status. Book the payout on paidout |
Both are dispatched by the settlement sync — the scheduled
larsasub:sync-settlements or your own SettlementService::syncTenant() /
syncPlatform() call — after the payout's payments were attached, so
$event->settlement->payments() is complete when you read it. A pass that
fails between the row and its payments announces nothing; the next run redoes
it and announces then. There is no event per attached payment.
A queued listener re-reads the settlement when it runs; if the row was deleted
in between, set public $deleteWhenMissingModels = true; on it, or the job
fails before handle(). See Settlements.
Billable events
| Event | Payload | Dispatched when |
|---|---|---|
VatNumberCheckedEvent |
billable, status, previous (both Enums\VatNumberStatus; previous usually pending) |
The VIES check of a registration's VAT number reached another status |
It fires whenever a check runs — with invoicing.vat_validation.enabled on,
but also for the --unchecked backfill and checkNow(), which run while it is
off — after the write commits, and only on a real change. A listener that mails
customers therefore fires during a backfill too. A re-check that confirms
valid again, or gets no answer and keeps it, is not announced.
The package mails nobody about a check. Listen here to tell a customer their
number was refused, or to keep the consultation number
($event->billable->vat_check_reference) in your own administration. A queued
listener re-reads the billable when it runs, which may already carry a newer
check. See Validating VAT numbers.
Dunning events
The reminder series. Every one carries case
(a DunningCase) and recurringCharge.
| Event | Payload (besides case, recurringCharge) |
Dispatched when |
|---|---|---|
DunningStartedEvent |
— | A series was opened for an unpaid charge |
DunningReminderDueEvent |
stepIndex, final (the last reminder of the schedule) |
A step reminds the customer — a notify step, or an escalate step with 'notify' => true |
DunningEscalatedEvent |
stepIndex |
An escalate step was taken. What happens now is yours to decide — the package never cuts off access |
DunningExhaustedEvent |
stepIndex |
The last step was taken and the charge is still unpaid; fires with that step's other events |
DunningResolvedEvent |
status, closedReason |
The series ended: status is resolved_paid, or resolved_closed with closedReason |
stepIndex counts from 0 into $case->schedule — the schedule the case kept
when it opened, not the current config.
At least once. A run dispatches after its commit, and a later run sends again what did not go out — so the same event can reach you twice. The one exception: a step whose series resolved before its events went out is dropped, and never sent late. So:
- Deduplicate on (event class, case, step) — one step can fire a ReminderDue, an Escalated and an Exhausted.
- Re-read the case before you act, and act unless it is resolved:
$event->case->fresh(), then skip when->status->isResolved(), and treatpausedas "not now". A queued listener whose case a retention prune removed meanwhile fails beforehandle()— setpublic $deleteWhenMissingModels = true;on it to drop such a job. The events of the LAST step always readexhausted— that case is not finished, only out of steps; checkingisOpen()would drop every escalation of the default schedule. A queued listener can also handle a Resolved before the Escalated that preceded it. - For access, ask
paidThrough(). Escalating a customer who already paid locks out a paying customer. A closed series reopens nothing by itself.
A listener that throws is logged and the run carries on: what committed
stays, the run counts dispatch_failed and exits with a failure, and the
event is sent again by a run at least 15 minutes later — while the series is
on. A Resolved from a payment while the series is switched off waits until it
is switched on again.
For a step's events (sent again together, with their siblings of that step) the
series takes no next step until they went out; a Started that did not go out is
retried every run but holds nothing back. close() never throws on your
listener's account. Prefer ShouldQueue listeners: a synchronous one runs
inside the scheduled command — and, for a Resolved after a payment, inside the
paid webhook.
To remind customers yourself (sms, in-app), listen to
DunningReminderDueEvent — and switch the package's own reminder off
(notifications.user.dunning_reminder, or SendDunningReminderToUserListener
in notifications.disabled_listeners), or the customer gets both.
Scheduler-only events
These are dispatched exclusively by the scheduled commands. On a host without
a running scheduler, or with scheduling.enabled = false, they never fire — and
neither do the mails that listen to them:
BillableSubscriptionStartedEvent—larsasub:detect-started-subscriptionsBillableSubscriptionRenewedEvent—larsasub:renew-billable-subscriptionsBillableSubscriptionRenewalAnnouncedEvent—larsasub:announce-renewal-billable-subscriptionsBillableSubscriptionEndedEvent— the end-date path only (larsasub:end-billable-subscriptions); the operator'sterminateSubscription()needs no schedulerRecurringChargeNotifiedEvent—larsasub:notify-chargesCreditExpiringEventandCreditExpiredEvent—larsasub:notify-expiring-creditsandlarsasub:process-expired-creditsDunningStartedEvent,DunningReminderDueEvent,DunningEscalatedEvent,DunningExhaustedEvent, andDunningResolvedEventexcept fromDunningService::close()and a payment — all fromlarsasub:process-dunning
Two more depend on a scheduled pass without being exclusive to it: the
MollieDisconnectedEvent of a drain (above) and
the settlement events, which your own sync call fires
too.
If your app provisions or de-provisions on one of these, the scheduler is not optional. See Scheduled commands.
Adding a property to a queued event
Relevant if you fork or extend the package, and the reason some LarsaSub events carry nullable properties that look like they could be required.
A property added to an existing queueable event or notification gets its default on the declaration, not only on the constructor parameter:
public ?ActivationSource $source = null; // ✅
unserialize() never runs the constructor, so a payload queued before the release
leaves the property uninitialized and the first read throws an Error. ?->
does not help — uninitialized is not null.
And such a property must be nullable with a default, never a required non-nullable type: a non-nullable one has no upgrade path except draining the queue before deploying, which is a step the host has to get right.