A billing rewrite introduced durable export records before removing several legacy queue jobs. Each new worker carried a record identifier. The export type, payload snapshot, attempts, failure details, and retry state lived in the database. A registry selected the current handler.
That order matters. A queued object is not merely code waiting to run. Its serialized class name and properties are data. Delete the class while an old payload remains, and PHP can deserialize it as an incomplete object which no longer has the method the worker expects.
The retained history proves the new durable path and the later deletion of old jobs. It does not prove that old queue payloads remained at deletion or that an incomplete object failed in production.
The risk exists because deprecation crosses time. A warning changes current source. A migration accounts for producers, stored consumers, adoption, drainage, recovery, and the point at which an old identity can no longer return.
A warning reaches only code which runs
An ordinary deprecation warning can reach:
- a developer calling an old method;
- a test executing an old path;
- a compiler or analyzer inspecting current source; or
- a live request which still uses the old interface.
It cannot update a payload already stored in a queue, a scheduled command on an old server, a webhook retried by another system, a mobile client which has not upgraded, or a database row containing an old type discriminator.
Those consumers have different clocks:
source consumer
changes when code is deployed
durable internal consumer
changes when stored work is drained, converted, or discarded
external consumer
changes when another owner adopts the replacement
historical reader
changes when old data expires or remains readable foreverA deprecation plan which says only “remove after two releases” assumes every consumer moves on the release clock. Many do not.
Inventory identities, not just call sites
Searching for method calls is necessary. It is not a complete consumer inventory.
Look for the old identity in:
- queue payloads and failed-job storage;
- scheduled tasks and deployment scripts;
- event names, routing keys, and webhook registrations;
- polymorphic types and enum values;
- cache keys and lock names;
- database records and audit history;
- command signatures and operator runbooks;
- public request and response fields; and
- independent repositories and deployed clients.
The identity may survive even when the implementation has moved. Renaming a queue job changes a serialized class name. Renaming an event changes what a consumer routes. Replacing an enum case changes how historical rows hydrate.
The inventory should record four facts:
| Identity | Producer | Durable location | Maximum lifetime |
|---|---|---|---|
| old job class | application release A | queue and failed jobs | retry plus retention |
| old event name | releases A and B | broker and consumer backlog | backlog plus replay |
| old record type | historical imports | database | retention policy |
| old endpoint | external clients | client deployments | adoption evidence |
“No references in the current repository” answers none of the lifetime questions.
Store intent outside the executable wrapper
The retained export path separated business intent from queue execution.
Conceptually:
durable export
id
type
input context
prepared payload
attempt count
last failure
terminal timestamps
queue job
export idThe job could be replaced because the work survived outside its serialized object. A current worker loaded the record, resolved a handler from the persisted type, attempted delivery, and recorded failure or completion.
That design does not make every rename safe. The queued wrapper still has a class identity, and the persisted type still needs a compatible reader. It does reduce how much business state is trapped inside one executable payload.
For durable work, prefer:
stable intent record + replaceable dispatcherover:
serialized implementation object as the only record of intentThe first form gives migration code something inspectable to convert or redispatch. The second may leave the old class as the only decoder.
Compatibility needs an adoption path
A complete deprecation can be staged:
- Define the replacement and its observable parity.
- Make new producers write the replacement form.
- Keep readers able to interpret supported old forms.
- Measure remaining old production and consumption.
- Convert, drain, replay, or classify retained old work.
- Stop every old producer, including rollback releases.
- Wait through the maximum declared lifetime.
- Remove the compatibility reader and old implementation.
Some systems need dual-write or dual-read during the transition. Others can use a one-time conversion. The correct choice depends on authority and rollback.
Dual-write helps when old and new consumers must coexist, but it creates agreement work and duplicate-effect risk. Dual-read is often safer for stored data because one producer can write the new form while readers continue to accept old records. A stop-the-world conversion can be simpler when the data set is bounded and downtime is acceptable.
The migration must say which one applies.
Drainage is a proof obligation
“The queue is empty” is too vague.
Which queue? Which failed-job store? Which delayed set? Could an older release enqueue another old payload after the check? Can a replay tool restore archived messages? Does disaster recovery restore a snapshot from before the cutover?
Useful removal evidence includes:
- zero old-form production for a declared window;
- zero ready, delayed, reserved, and failed old payloads;
- no scheduled or operator path which can recreate them;
- rollback releases either disabled or able to read the new form;
- old external consumers below an agreed threshold or explicitly unsupported;
- historical records converted or covered by a retained reader; and
- recovery procedures tested against pre-cutover state.
The window should exceed the longest supported delay, retry, retention, and rollback horizon. If those horizons are unknown, the removal date is unknown.
Discard can be the migration
Not every old message deserves replay.
A queued cache refresh may be obsolete. A notification may be too old to send. A billing export, payment, entitlement change, or destructive operation may still represent durable business intent.
Classify retained work before deletion:
| Class | Treatment |
|---|---|
| superseded computation | discard with count and reason |
| reconstructable work | regenerate from authoritative state |
| durable business intent | convert or execute with idempotency control |
| unknown outcome | investigate; do not silently replay |
| legally retained history | preserve a compatible reader |
Discarding is safe only when the consequence is understood and recorded. A bulk queue purge is not a migration plan merely because the new code no longer recognizes the payloads.
The old decoder may need to outlive the old behavior
Compatibility code is often treated as embarrassing residue. During a migration, it can be the component which makes removal safe.
Keep the narrowest reader needed to:
- decode an old identity;
- validate its version;
- translate it into current intent;
- reject unsupported or ambiguous state explicitly; and
- record whether conversion, discard, or manual review occurred.
The reader should not keep producing old work. It should have telemetry and a removal condition. That turns compatibility into a temporary migration tool rather than a permanent second architecture.
The public reproduction serializes a synthetic legacy job identity and reads it without the class definition. PHP produces an incomplete object. The same fixture then reads versioned JSON intent through a current registry and preserves the record identifier without loading the removed class.
That proves the language mechanism and one alternative shape. It does not model a queue framework, production payload, or the retained billing system.
Removal is complete when resurrection is bounded
Code deletion is the visible end of deprecation, but it is not the completion criterion.
Removal is ready when:
- supported producers cannot create the old form;
- retained old work has a declared treatment;
- adoption and drainage evidence covers the maximum lifetime;
- rollback and disaster recovery cannot silently resurrect incompatibility;
- the replacement preserves the required outcome; and
- any remaining reader has an owner and expiry.
A warning can begin that work. It cannot perform it.
Deprecation is a migration from one living contract to another across every place the old identity can wait. Remove the old code only after those places are empty, converted, deliberately discarded, or still served by a bounded reader.