Dependency injection feels simple when the application process is short-lived.
A request comes in.
The framework resolves a few services.
The request finishes.
Then everything disappears.
Under that model, a lot of lifetime mistakes are easy to miss.
But persistent PHP workers change the rules.
Once the same application process handles multiple requests, jobs, or messages, dependency injection stops being only about convenience.
It becomes part of runtime safety.
That is one of the reasons EvolvePHP 2 treats service lifetimes as an architectural concern rather than a container feature.
The easy version of dependency injection
In a typical application, we might have something like:
final class BillingService
{
public function __construct(
private PaymentGateway $gateway
) {}
}That is straightforward.
The container creates BillingService, injects PaymentGateway, and the application uses it.
Most developers understand this part.
The harder question is:
How long should each of those objects live?
That question becomes much more important when the PHP process itself stays alive.
Imagine the application boots once
A persistent worker might look like this:
Application boots
↓
Request A
↓
Request B
↓
Request C
↓
Request DNow imagine we have a service created during application boot:
BillingService
Lifetime: ApplicationAnd BillingService depends on:
CurrentUser
Lifetime: RequestConceptually:
Application-scoped BillingService
↓
Execution-scoped CurrentUserThat relationship is dangerous.
Why?
Because BillingService may live for hours.
CurrentUser should live for one request.
If the long-lived service captures the short-lived object, the user from Request A can remain reachable during Request B.
This is called a captive dependency
The general problem is often known as a captive dependency.
A longer-lived service captures a dependency with a shorter lifetime.
For example:
Application
↓
Executionor:
Singleton
↓
Request-specific stateIn a disposable request model, the process may end before this becomes visible.
In a persistent worker, it can turn into:
Request A
User: Alice
↓
BillingService retains Alice
↓
Request B
User: Bob
↓
BillingService still references AliceThat can become more than a bug.
If the state involves users, tenants, authorization, transactions, or sensitive data, it becomes a security problem.
This is why lifetimes need names
For EvolvePHP 2, I am working around three basic lifetimes:
Application
Execution
TransientApplication
Lives as long as the booted application.
Good candidates might include:
immutable configuration,
route metadata,
shared infrastructure,
reusable stateless services.
Execution
Lives for one unit of work.
That could be:
one HTTP request,
one queue message,
one scheduled job,
one CLI operation,
one worker task.
Typical execution state might include:
current user,
current tenant,
request context,
transaction context,
trace context,
authorization state.
Transient
Created when needed and not automatically shared.
This gives the container more information than:
“Can I build this object?”
It can also ask:
“Is this dependency relationship safe?”
Some dependencies should be rejected
Suppose the container sees:
ApplicationService
↓
CurrentTenantand CurrentTenant is execution-scoped.
Instead of allowing it and hoping the developer resets everything correctly, I would rather the framework reject the relationship.
Something like:
Invalid lifetime dependency:
Application-scoped service
cannot directly depend on
Execution-scoped service.That is much better than discovering the mistake through a cross-tenant production incident.
A framework should make unsafe architecture difficult.
The opposite direction is usually fine
This relationship makes much more sense:
Execution Service
↓
Application ServiceFor example:
CheckoutHandler
↓
PaymentGatewayThe execution-scoped handler can safely depend on a long-lived, stateless payment gateway abstraction.
When the execution finishes, the handler disappears.
The shared service continues.
The important rule is that the shared service does not retain the execution-specific object.
Service locators can hide the problem
There is another pattern that makes lifetime issues harder to see:
$container->get(CurrentUser::class);from anywhere in the application.
This can feel convenient.
But now dependencies become invisible.
A class may appear to have no dependency on current user state while secretly resolving it from a global container.
That makes architecture harder to reason about.
It also makes testing and persistent-worker safety harder.
I prefer explicit dependencies where practical.
A constructor tells us:
This service needs these things.That gives both developers and tooling something concrete to inspect.
Factories do not automatically solve it
A common workaround is:
“Just inject a factory.”
Sometimes that is correct.
For example, an application-scoped service might receive a factory that creates an execution-specific object only during the active execution.
But the important part is still ownership.
The long-lived service must not keep the result beyond the execution.
Otherwise we have only hidden the captive dependency behind another abstraction.
Cleanup still matters
Correct lifetimes reduce risk, but some infrastructure still needs reset behavior.
A connection may retain transaction state.
A logger may carry execution context.
A telemetry system may hold baggage.
An event dispatcher may temporarily register execution-specific listeners.
So persistent execution still needs:
Execute
↓
Cleanup
↓
Reset
↓
Verify
↓
Reuse or QuarantineDependency lifetimes and cleanup work together.
Lifetimes prevent unsafe ownership.
Cleanup removes temporary state.
Both matter.
DI becomes architecture, not just plumbing
This is the bigger point.
In short-lived applications, dependency injection is often discussed as:
easier testing,
cleaner constructors,
replacing implementations,
avoiding manual object creation.
Those are useful benefits.
But in long-running PHP, DI also answers:
who owns state,
how long it survives,
what may depend on what,
whether process reuse is safe.
That changes its importance.
This influences EvolvePHP deeply
I do not want persistent-worker support to be something added after the framework is already designed.
If the container allows unsafe lifetime relationships from the beginning, adding RoadRunner or FrankenPHP later means auditing everything after the fact.
I would rather start with the safer model.
Ask every service:
What lifetime does this belong to?
Then enforce the rules around it.
Because persistent PHP does not just make applications faster by keeping them warm.
It also makes old assumptions about object lifetime visible.
And once the process lives forever, dependency injection is no longer just about how objects are created.
It is about making sure the wrong state does not live forever with them.

No comments:
Post a Comment