Index

Data That Must Survive

Data Outlives the Code That Created It

A failed queue record kept returning. Its payload named a mail class the running application could not resolve correctly, so retrying it could only repeat the same failure.

The retained workaround was blunt: once a minute, delete failed records whose payload matched that job. It stopped an unusable delivery attempt from disturbing the queue. It did not make the payload readable.

That small incident exposes an awkward storage contract. Serializing an executable object does not merely store its arguments. It preserves facts about the code which created it: class name, namespace, property layout, and sometimes framework behavior. The payload can live much longer than those facts.

A class name becomes part of the stored format

Consider a queued object with one property:

final class LegacyMailJob
{
    public function __construct(
        public string $recipientId,
    ) {}
}

$payload = serialize(new LegacyMailJob('recipient-demo-7'));

The bytes include the fully qualified class name. Read them in a process where that class is unavailable and PHP produces an __PHP_Incomplete_Class. Properties may still be visible, but the methods and behavior which made the object executable are gone.

The public fixture accompanying this post runs exactly that sequence in separate PHP processes. It proves a language mechanism, not the details or frequency of the retained queue failure.

A namespace cleanup can therefore be a data migration even when no database column changes. So can renaming a job, altering custom serialization, removing an enum case, changing a constructor assumption, or deploying old and new workers at the same time.

Source compatibility asks whether current callers can construct the new code. Stored compatibility asks whether the new code can interpret everything already waiting.

Retry cannot restore missing behavior

A retry system changes when an attempt happens. It does not change what the payload means.

stored payload
    -> deserialize
    -> resolve executable type
    -> run behavior

If deserialization cannot resolve the type, another attempt follows the same path to the same boundary. Backoff is still useful because it limits pressure, but no delay can recreate a class which the deployment removed or named incorrectly.

This separates two questions which queue dashboards often blur:

  1. Can this delivery attempt run again?
  2. Must the underlying business intent still happen?

Deleting a broken attempt is safe only when the second answer is known. Perhaps the intent is obsolete, already complete, reproducible from durable state, or represented by a newer command. If the serialized job is the only surviving record of the intent, deleting it is data loss with tidy queue metrics.

The retained history establishes that a matching failed record was repeatedly deleted as a temporary mitigation. It does not establish why it returned, how many records existed, whether the mail eventually went out, or where the business intent lived. Those limits matter more than a plausible reconstruction of the incident.

Store intent as data, then choose current behavior

An alternative representation keeps the durable fact independent of the class which happens to process it:

{
  "type": "onboarding-help.requested",
  "version": 1,
  "event_id": "event-demo-19",
  "recipient_id": "recipient-demo-7",
  "requested_at": "2026-12-01T09:00:00Z"
}

A current dispatcher maps (type, version) to current behavior:

$handlers = [
    'onboarding-help.requested:1' => SendOnboardingHelp::class,
];

This does not eliminate compatibility work. It makes the work visible.

The envelope still needs a schema, validation, and an ownership rule. A reader must either support version 1, migrate it, or reject it deliberately. The important change is that refactoring a handler no longer silently renames the stored fact.

Use identifiers and approved scalar values in the envelope. Avoid dumping an ORM model or arbitrary object graph into it. Those objects import more storage contracts: table-backed identity, lazy-loading assumptions, casts, relationships, and private property layouts.

Version only where interpretation changes

Versions are not decoration and should not increment with every refactor. They mark an incompatible change in meaning or required shape.

Suppose version 1 means “send help to the account’s current contact,” while version 2 carries a specific recipient selected when the request was created. Those versions describe different intent:

version 1:
    recipient resolved when handled

version 2:
    recipient fixed when requested

A migration might enrich waiting version 1 records. A compatibility handler might preserve their original late-binding behavior. Or the product owner might authorize discarding them after a defined age. Each choice is explicit and testable.

Adding an optional trace identifier, by contrast, may not change interpretation at all. Versioning every additive field creates branches which carry no useful decision.

Compatibility has a deployment window

Even a clean envelope does not remove rolling-deployment risk. Old producers, new producers, old workers, and new workers may overlap:

             reads v1    reads v2
old worker      yes         no
new worker      yes         yes

             writes v1   writes v2
old producer    yes         no
new producer    maybe       yes

If the new producer writes version 2 before every eligible worker can read it, the deployment creates poison messages by design.

A safe sequence is usually:

  1. deploy readers which accept old and new formats;
  2. verify that the reader population is compatible;
  3. enable new writers;
  4. drain or migrate old records; and
  5. remove the old reader only after durable evidence shows it is unused.

The same expand-and-contract reasoning applies to renamed classes when object serialization cannot yet be removed. Keep a compatibility alias or shim, deploy it before the rename begins producing new payloads, inspect every durable store, then remove it only after the longest retention and retry window.

A compatibility inventory is broader than the main queue

Before removing a type, search all places where its name or representation can survive:

  • ready, delayed, reserved, and failed queues;
  • scheduled jobs and framework caches;
  • database JSON or binary columns;
  • session and cache values;
  • dead-letter stores and replay archives;
  • exports, snapshots, and backups which may be restored; and
  • messages held by another service.

The longest-lived reader may not be production. A six-month-old backup restored for an investigation can reintroduce a representation the current application forgot years ago.

This does not mean every internal object deserves permanent support. Compatibility has a cost. It means removal needs a bounded decision: which stores were inspected, which payloads were migrated or allowed to expire, which attempts may be discarded, and which business intent must be rebuilt first.

Test the old bytes with the new reader

Round-trip tests serialize and deserialize with one version of the code. They miss the boundary that breaks during an upgrade.

Keep representative old payloads as fixtures and run them through the current reader:

given:
    a version 1 envelope produced by the previous release

expect:
    current validation accepts it
    current dispatch selects the intended handler
    unknown versions fail with a classified error
    no side effect occurs before validation completes

For executable-object payloads, include a fixture from before a planned rename and exercise it after the compatibility shim is added. Also test the removal gate: the shim cannot disappear while a supported store still contains the old name.

Queue retention is then part of release planning rather than an unpleasant surprise in the failed-jobs table.

The practical boundary is simple. A delivery attempt may be temporary. Business intent may not be. Persist the latter in a format whose meaning you own, and let current code decide how to execute it.