Skip to content

Runtime & Architecture / Built to stay in memory

Long-Running PHP: Inside the Semitexa Runtime

By ·

The first request works. The second remembers more than it should. Keeping a PHP application alive makes its lifetimes part of the architecture: which objects should survive, which data belongs to one execution, and what happens while two requests overlap? Semitexa was designed from day one around those questions.

Persistent workers are Semitexa's starting point. Its Swoole runtime, dependency container, execution contexts, connection pools and server rendering are built around an application that stays in memory. This guide follows that design from worker startup to the HTML in your browser, with a small example running on this page.

Designed for long-running PHP from day one

Imagine a dashboard showing an order as it moves from payment to fulfillment. The first page should arrive quickly. A slow recommendation service should not hold up the order details. A background job should be able to publish progress. Several people may be watching, each with their own session and permissions.

That application benefits from more than a faster request handler. It needs reusable infrastructure, isolated execution state, efficient waiting, live delivery and a way to see what is happening inside the server. Semitexa treats those needs as parts of the same runtime design.

Semitexa was created for the long-running execution model from the beginning. The worker is a first-class lifetime. The container distinguishes shared services from execution-scoped objects. Request context is carried through the framework. Coroutines, background work and streamed HTML have a place in the architecture.

The practical advantage is consistency. You can follow a typed payload into a handler, resolve dependencies with explicit lifetimes, produce a resource, and render or stream it using the same application model. The framework can make decisions at startup because the resulting application will serve many executions.

Other PHP runtimes also keep applications in memory; FrankenPHP's worker documentation is a useful introduction to that broader model. This article examines Semitexa's own Swoole-based implementation. Its strength is how the framework's pieces are designed to work together within that model.

The worker lives longer than the request

In a conventional PHP-FPM deployment, a worker process can already serve many requests. The application is normally initialized within each request's PHP execution, with ordinary userland request state torn down afterward. Long-running application servers keep the initialized application itself available across requests.

OPcache and application persistence address different work. OPcache caches compiled script bytecode. A persistent application can also reuse its bootstrapped service graph and discovered metadata. Neither mechanism makes database queries, remote APIs or expensive business logic disappear.

Worker starts→ discover and buildWarm application
Request A→ its context + handlerResponse A
Request B→ its context + handlerResponse B
One worker can reuse its application while each request receives its own execution state. With coroutines, request A and request B may overlap while waiting for I/O. This is an architectural diagram, not a timing measurement.

The useful distinction is ownership. A service containing stable behavior can live with the worker. The current customer's identity belongs to an execution. A database connection is a resource to borrow for bounded work. A cross-worker counter needs an explicitly shared store.

LifetimeExamplesDesign consequence
WorkerShared services, container metadata, handler prototypesReuse them; keep request identity out of their mutable fields.
ExecutionHandler clone, request, auth, tenant and locale contextResolve them for the current execution and release them afterward.
Borrowed operationDatabase or Redis connectionReturn it after use; bound how long a caller can wait.
Explicitly shared stateDatabase records, Redis data, selected Swoole tablesChoose storage and atomic operations appropriate to the sharing boundary.

A PHP static property is local to its process and can survive many executions. It is therefore a poor place for “the current user,” and it is not automatically a counter shared by every worker. Understanding both boundaries prevents two very different classes of mistakes.

Try it: a persistent worker, a fresh handler

The following values are produced by the PHP handler serving this article. Its $handled property starts at zero and is incremented when handle() runs. Semitexa's execution-scoped resolution supplies a fresh handler clone for the next request.

Live result from this response

Which state survived?

Worker process ID
11
Calls on this handler instance
1

Rendered at · UTC

Run it again. The handler counter should still be 1. You may see the same worker process ID, or another one when the server distributes requests across workers. A reload or deployment can also change the process ID. A repeated ID with a fresh counter illustrates the two lifetimes directly.

This is an actual PHP result, with caching disabled for the response. It is a small demonstration of handler resolution; it does not by itself prove concurrent tenant isolation or measure throughput. We will run the relevant concurrency tests later.

PHP · the handler's scalar state starts fresh on each resolved clone
// BlogArticleHandler: excerpt from the handler serving this page.
// #[AsPayloadHandler] makes the handler execution-scoped.
private int $handled = 0;

// Inside handle(), when this article is requested:
return $resource->withRuntimeProbe(
    workerPid: (int) getmypid(),
    executionCount: ++$this->handled,
    renderedAt: gmdate('Y-m-d\TH:i:s\Z'),
);

The handler does not contain a counter-reset instruction. The lifetime is established when the container resolves it. If an application deliberately keeps that clone in a global variable, or puts the counter in a static property, it has chosen a different lifetime.

A dependency container that knows what should survive

Semitexa's container performs module discovery, attribute scanning, contract resolution, injection analysis and graph construction during its build. It stores shared service instances and execution-scoped prototypes, then seals registration. Each HTTP worker builds its container during startup.

That moves structural work out of the ordinary request path. The worker can reuse known bindings and injection metadata while resolving the objects needed for the next execution. The payoff is less repeated bootstrapping and a clear place to diagnose wiring problems.

Two injection attributes make the lifetime distinction visible in application code. #[InjectAsReadonly] supplies a worker-scoped dependency. #[InjectAsMutable] supplies execution-dependent values or services when the scoped object is cloned. Payload handlers, event listeners and pipeline listeners imply execution scope; other applicable services can declare #[ExecutionScoped].

PHP · property injection expresses which dependency belongs to which lifetime
// Properties on an execution-scoped payload handler:
#[InjectAsReadonly]
protected SiteBlogCatalog $blog;

#[InjectAsMutable]
protected Request $request;

// The catalog is reusable across executions.
// Request is resolved from the current execution context.

The shared attribute describes the injection lifetime; it does not make every property inside the injected object immutable. A shared service still needs appropriate state discipline. Similarly, PHP cloning is shallow: arbitrary mutable child objects do not become independent just because their parent was cloned. Execution-dependent dependencies should use the framework's scoped resolution.

You can inspect the implementation in ContainerBootstrapper and SemitexaContainer. The latter's resolution path clones scoped prototypes and injects the current context. This is the mechanism behind the counter above.

Let requests overlap without sharing their identity

A request waiting for a database response should not have to monopolize a worker that could serve another request. Semitexa's server enables Swoole coroutines and runtime hooks before creating the HTTP server. Supported blocking operations can yield so another coroutine can make progress.

This is especially useful for I/O-heavy pages, upstream API calls and long-lived streams. It does not turn a CPU-heavy PHP loop into parallel work, and runtime hooks only cover supported operations in the installed Swoole build. CPU-bound work still needs an appropriate process or job strategy.

Overlapping requests make isolation more demanding. Clearing a global “current user” at the end of a request cannot protect another request that was already running while the first one yielded. The current identity must be attached to the execution that owns it throughout the overlap.

Semitexa's ExecutionContext carries request, session, cookies, tenant, auth and locale values. The container stores those bindings through CoroutineLocal, which uses Swoole's coroutine context during coroutine execution. Resolving a mutable dependency after a yield therefore reads the current coroutine's context.

The container itself remains shared. The execution bindings do not become an ordinary field on that shared container. This separation is one of the most important strengths of a framework designed around persistent, concurrent workers.

Child coroutines have a deliberate boundary too. A new child does not automatically inherit its parent's execution context. Framework code that needs propagation captures the context and applies it explicitly:

PHP · explicit propagation when writing a custom coroutine integration
// Runtime integration code with an execution-context-aware container.
$snapshot = $container->captureExecutionContext();

\Swoole\Coroutine::create(function () use ($container, $snapshot): void {
    $container->runWithExecutionContext($snapshot, function () use ($container): void {
        // Resolve context-dependent services inside this callback.
        $request = $container->captureExecutionContext()->request;
        // Perform the child operation using its explicit context.
    });
});

runWithExecutionContext() restores the previous bindings in a finally block. The snapshot carries references to context objects; it is not a deep copy that makes arbitrary shared mutations safe. Ordinary payload handlers receive their dependencies through injection; this lower-level API matters when implementing runtime integrations.

The CoroutineLocal implementation also provides a process-local fallback for CLI execution. Request lifecycle cleanup remains necessary there, where one process may perform several executions without Swoole coroutine teardown.

Reuse connections with clear ownership and bounded waiting

A persistent runtime can keep useful database connections open. It also needs to prevent two executions from accidentally treating the same connection as their own transaction. Connection reuse becomes an ownership problem as well as a performance opportunity.

Semitexa ORM's coroutine-aware pool uses a bounded Swoole channel. A caller borrows a connection, uses it and returns it. Pool acquisition has a timeout and reports exhaustion. The pool can recover abandoned borrows when a coroutine ends, and it tracks events such as discards and exhausted waits.

That gives the application an explicit capacity boundary. When available connections are occupied, callers wait within a limit instead of creating an unbounded number of connections. Pool capacity is per pool in a worker process; size the deployment against the total number of workers and the database's connection budget.

Returning a connection includes transaction hygiene. If PDO reports an open transaction, the pool attempts a rollback before reuse. If that cleanup fails, the connection is discarded. This prevents a later borrower from inheriting an unfinished transaction on that path. The check relies on PDO's transaction tracking, so raw SQL transaction commands are not a substitute for the managed transaction API.

The implementation also recognizes process changes so inherited connection resources are not casually reused after a fork. These details matter because the resource outlives any single request.

The ORM ConnectionPool source shows the checkout, return, timeout and reclaim paths. The application-level lesson is straightforward: keep borrowed resources close to the work that uses them, release them promptly, and avoid holding a database connection while waiting on an unrelated remote service.

The same runtime can deliver the page and keep it alive

The dashboard from the opening example has several useful moments to render. Order details are ready now. Recommendations arrive later. Fulfillment progress changes while the visitor is watching. Semitexa's rendering model can represent each moment without relocating the business rules into the browser.

A PHP handler fills a resource and Twig renders the initial HTML. A deferred region can arrive when its slower work finishes. SSE can carry subsequent updates. Coroutines let supported I/O waits yield while the worker handles other work; the connection and its resources still have a cost that the deployment must budget.

This combination is a practical runtime strength: reusable application services, execution context and server rendering participate in one flow. A shipping rule can remain in PHP while its result appears in the first response, a deferred block or a later live update.

Our Server Side Rendering 2.0 guide contains a working shared-template example. The Streaming SSE guide explores live delivery. Read them as concrete applications of the lifetime and concurrency model described here.

A live connection also crosses operational boundaries. Proxies need suitable buffering and timeout settings. Clients need reconnection behavior. Authentication and tenant isolation still apply to streamed content. Semitexa provides framework mechanisms for live delivery; a deployment must configure the network path around them.

A long-running worker must also know how to stop

Startup is only part of a persistent runtime. A deployment eventually replaces workers. Timers may still be firing, an SSE connection may be open, and a coroutine may be waiting on a socket. A complete lifecycle has to account for that work.

Semitexa exposes server lifecycle phases around startup and shutdown. Its current Swoole configuration enables asynchronous reload with a finite drain window. On worker exit, the bootstrap raises a drain signal, clears timers and attempts to cancel parked coroutines. It reports coroutines that refuse cancellation so a stalled shutdown has evidence.

This is bounded shutdown, not a promise that every in-flight operation completes. Some driver calls cannot be interrupted immediately, and Swoole may force termination after the configured wait. Work that must survive a worker replacement needs durable job handling, appropriate retries and idempotency.

The same ownership principle appears at smaller boundaries. HTTP handling resets registered per-request state and disposes request-scoped bindings in cleanup. Queue workers perform per-message cleanup. Scheduled execution restores a previous tenant context after a tenant-bound run. A process can stay alive while the framework closes an individual unit of work.

These mechanisms are visible in SwooleBootstrap and its server configuration. For application developers, they provide explicit places to attach lifecycle behavior and explain why an unbounded background loop needs an exit strategy.

Inspect the behavior and make the boundary executable

Long-running behavior becomes easier to trust when you can examine more than a successful first response. Semitexa's development tooling connects route structure, active processes and recorded execution. The tools operate on the same application whose lifecycle you are investigating.

Terminal · inspect the article route and a source-linked development trace
bin/semitexa ai:ask route \
  --path=/blog/long-running-php-semitexa --json

bin/semitexa ai:observe ps

# In development, visit the article with ?__trace=1 first.
bin/semitexa ai:observe tail \
  --kind=http --name=BlogArticle --lines=4 --json

# Replace p-YOUR-ID with the request id from the journal.
bin/semitexa ai:observe show --id=p-YOUR-ID --source

Route inspection identifies the payload, handler, resource and template. Observatory lets you inspect process lifecycles and, for a traced development request, the recorded handler execution. Full traces and source views in this walkthrough require development mode.

The stronger isolation check is an interleaving test. Two coroutines install different request contexts, both yield, and then each resolves a context-dependent object. Each must still receive its own request. The core test suite also checks that a child starts without the parent's context and that explicit propagation restores the intended values.

Terminal · run the actual context isolation and lifecycle regression tests
# Run from the Semitexa development workspace with its Swoole runtime.
bin/semitexa test:run \
  packages/semitexa-core/tests/Unit/Container/SemitexaContainerExecutionContextIsolationTest.php

bin/semitexa test:run \
  packages/semitexa-core/tests/Integration/RequestScopedContainerLifecycleTest.php

These commands target tests in the development workspace; an installed distribution may not include that test tree. Coroutine cases require the Swoole extension and are skipped without it. Check the result for skipped tests as well as failures. The concurrent isolation test source shows exactly which boundary is exercised.

Performance needs its own evidence. Compare cold and warm behavior on the same workload, then measure latency under concurrency, memory over sustained traffic, database wait time and failure recovery. Avoid inferring an application-wide speedup from a counter or a hello-world route. Semitexa supplies mechanisms that remove repeated work and manage concurrency; the gain depends on where your application spends time.

When editing a development checkout, reload the running application before testing changed server code: bin/semitexa server:restart app. CLI checks such as ai:verify run in fresh processes and do not need that restart. That distinction is another direct consequence of the worker lifetime.

Build around the lifetimes the application actually has

Semitexa's runtime design gives a PHP application a coherent set of building blocks: a warm service graph, execution-scoped handlers, coroutine-local context, bounded connection reuse, live server-rendered output and explicit worker shutdown. Observability and regression tests make those boundaries inspectable.

That is the significance of designing for long-running PHP from day one. The execution model shapes how dependencies are declared, how a handler is created, how state is carried, how a connection is returned and how HTML reaches the browser. Each part has a defined place in the application lifetime.

Start with one real feature. Keep stable services reusable, put execution-specific data in the appropriate scope, keep resource borrowing short, and test the second request as carefully as the first. Then add the overlapping requests and live updates that make the runtime's strengths useful.

Run the architecture

Build a PHP application that stays ready.

Install Semitexa, inspect one request, and follow its state from the worker to the rendered page.

Implementation references are pinned to the core and ORM revisions inspected for this article. Runtime details and command options can evolve; consult your installed version's help and source when applying an example.