Symfony Scheduler and Messenger: keeping recurring work out of HTTP requests
An application that periodically aggregates data has two different consumers: the clock that should trigger the work, and the reader who later asks for the result. Coupling both to an HTTP request makes failures hard to interpret. Did the request fail, did the external provider fail, or was the calculation never triggered? Separating trigger, calculation, persistence and retrieval makes each answer visible.
This article describes that separation with standard Symfony components — Scheduler, Messenger, HttpClient and Doctrine — using a deliberately generic example: an hourly snapshot of external market data whose stored history is served through an API. The example is illustrative, not a description of a specific production system. Class names are chosen to show responsibility, not to be copied verbatim.
The concrete effect is that reading saved history does not trigger a new calculation. A scheduled handler writes stored results; an HTTP controller reads them. Provider availability then becomes a property of the write path, not of every page view.
Check version assumptions first
Symfony publishes new minor versions twice a year and each release has a defined maintenance window. Before copying an example — including this one — check the Symfony release page for a maintained version and read the documentation that matches it. Attributes, transport names and defaults do change between releases.
A version constraint in a composer.json is a declaration, not evidence of what is installed or running. Verify the lock file and the deployed packages. Likewise, a recurring task defined in code does not execute by itself: something must consume the schedule, which the next section covers.
Compare cron, Scheduler and request-time work
Operating-system cron invoking a Symfony console command is a reasonable choice for one short, fixed periodic job. It needs no long-running consumer. Its limits are operational: the schedule lives outside the application's dependency injection, and every deployment must keep the external crontab aligned with the release.
Symfony Scheduler defines recurring messages in application code, so the schedule is version-controlled next to the handler and its dependencies. The trade-off is that a Messenger consumer must run, restart after deploys and be monitored. Defining a schedule produces messages; it does not create a worker. In a container deployment the consumer is typically a separate process or replica with its own restart policy, which is one more thing to get right than a crontab entry.
Calculating during an HTTP request is the simplest trigger but ties response time and success to the external provider. It can suit an explicit on-demand refresh with a clear timeout. It is a poor default for a history endpoint, whose job is to return records that already exist.
Follow the scheduled message
In the generic example, a provider declares an hourly message. This is a minimal but complete pattern from the Scheduler documentation:
#[AsSchedule('default')]
final class SnapshotScheduleProvider implements ScheduleProviderInterface
{
public function getSchedule(): Schedule
{
return (new Schedule())->add(
RecurringMessage::cron('0 * * * *', new StoreSnapshot()),
);
}
}
The cron expression emits StoreSnapshot at the start of each hour into the scheduler_default transport. Options such as stateful() and processOnlyLastMissedRun() control recovery after consumer downtime: they decide whether missed runs are replayed, not whether lost data can be reconstructed. A snapshot taken late still uses the data available at processing time.
The handler is registered with #[AsMessageHandler] and delegates to an application service. Delegation matters: the handler should coordinate, while fetching, aggregating and persisting live in testable collaborators. A completion log entry then proves only that the delegated call returned — which is why the failure path deserves its own section.
Make failure visible
The most consequential design decision is what the handler does when the external provider fails. Returning null and logging an error looks defensive, but it converts a failure into an ordinary return value. Messenger's automatic retry reacts to exceptions, not return values, so a swallowed error cannot be retried and a completion log can hide an empty result.
Throwing makes the failure explicit. The Messenger documentation describes the default retry policy and the optional failure transport, which stores failed messages for later inspection with messenger:failed:* commands. A failure transport needs its own storage — typically the Doctrine transport and a table — so it is a deliberate deployment decision, not a free feature.
Retries are bounded and idempotency still matters. A snapshot job that runs again should either be naturally repeatable — “store the current hour” is idempotent if the period is the unique key — or check before writing. For recurring scheduler messages the next tick is itself a retry, which is a simpler recovery model than replaying a queue.
Respect precision at the boundary
If the stored value is money or a measured quantity, the arithmetic must match the column. A decimal(18,8) column stores exact base-ten values; multiplying amounts and prices as PHP floats introduces binary rounding before the value ever reaches the database. Converting the float to a string afterwards does not recover the lost digits.
The practical fix is small: keep amounts and prices as decimal strings, calculate with the BCMath extension, and round once — at the persistence boundary — to the column's scale. Serialize the stored decimal as a string in the API response so the eight fractional digits survive JSON. Whether a chart then converts it with Number() is a presentation decision.
Keep the HTTP boundary honest
On the read side, the controller should only query stored rows. Query parameters still need explicit validation: a bounded integer for a “last N hours” filter, ISO 8601 dates for a range, a check that the range start precedes its end, and a maximum result count so an unfiltered request cannot stream the whole table. Invalid input should produce a client error, not an uncaught exception.
Timestamps deserve equal care. If the API suffixes a formatted time with Z, the value must actually be in UTC — formatting alone does not convert it. Set the timezone explicitly or store UTC at write time, then document the contract. The same discipline applies to authentication: a route definition says nothing about who may call it, so access rules belong in the security configuration and in the review checklist.
Test with fakes, not the network
Each boundary is testable without external calls. MockHttpClient from the HttpClient documentation returns scripted responses, so tests can cover a valid price, a malformed payload and a provider outage. A mocked entity manager or an isolated SQLite schema verifies that a failed valuation persists nothing, and that decimal input survives to eight places.
That coverage is worth more than an end-to-end test against the live provider, which is neither deterministic nor safe to depend on in CI. Assertions should target the contract: the exact stored string, the exception on failure, the rejected query parameters.
Practical conclusion and limits
The architectural lesson is to separate triggering, calculation, provider access, persistence and retrieval, and to make failure an explicit outcome rather than a logged detail. None of this requires microservices — a modular monolith with clear boundaries gets the same benefit.
Do not adopt the machinery by default. A single short task can stay on cron; a simple content site needs none of it. And when the result is financial, treat precision, retries and access control as requirements to specify, not behaviour to assume. For scoped implementation help, see Symfony development.
Laravel multi-tenant SaaS: domain routing and PostgreSQL isolation in VetSpace
A clinic platform needs to answer two questions before it reads a patient record: which organization owns the request, and which records may this user access? A domain name helps answer the first question. It does not answer the second, and it does not keep a background worker in the correct database context by itself.
This article examines those boundaries in the VetSpace Laravel API. It is a source-code analysis of tenant identification, PostgreSQL storage selection, provisioning and queued analysis. It is not a claim that an independent security assessment has proved complete isolation, or that the design has achieved a measured commercial outcome.
The useful result is a repeatable organization boundary without a separate codebase for every clinic. Domain resolution chooses the clinic context, provisioning creates its storage and initial records, and explicit cleanup prevents a queued task from leaving that context active. Shared central records still require their own authorization checks.
Compare the storage models before choosing one
In a shared-table design, rows carry an organization identifier. There is one schema to migrate and cross-organization reporting is straightforward. The cost is a pervasive scoping requirement: every query, relationship, export and background task must enforce the correct organization boundary. Database policies can add protection, but they need careful privilege and connection-context design.
A schema-per-tenant design gives each organization its own tables inside one PostgreSQL database. That avoids collisions between identically named tables and supports tenant-specific migrations. It does not create a separate server or an absolute access barrier. The PostgreSQL schema documentation explicitly explains that privileges determine access across schemas and that search_path affects name resolution.
A database-per-tenant design separates connection targets and can make individual backup and restore procedures easier to define. It also increases operational work: migrations, monitoring, connection management and recovery must cover many databases. Databases on the same cluster still share infrastructure. None of these choices alone demonstrates a regulatory compliance guarantee.
What VetSpace actually implements
VetSpace uses stancl/tenancy and a Tenant model implementing TenantWithDatabase, with the package's HasDatabase and HasDomains concerns. The package supports both PostgreSQL database and schema managers, as described in its multi-database documentation. The application's custom manager decides which one to use.
The important detail is that the decision is not simply “enterprise means database, everything else means schema.” Existing storage takes precedence. This expression is excerpted from UserRolePostgreSQLManager::resolveManager(); it depends on that class's existence checks and imported manager classes, so it is not a standalone provisioning script:
$managerClass = match (true) {
$this->existsAsDatabase($name) => PostgreSQLDatabaseManager::class,
$this->existsAsSchema($name) => PostgreSQLSchemaManager::class,
$plan === 'enterprise' => PostgreSQLDatabaseManager::class,
default => PostgreSQLSchemaManager::class,
};
The manager checks PostgreSQL's database catalogue and schema metadata through the central connection. Only when neither storage form exists does the plan choose the initial layout. That preserves older tenants created with their own database even if their current plan is no longer enterprise. A plan change does not migrate their data. Any storage move needs a separately designed copy, validation and cutover procedure.
Separate the central and clinic APIs
The repository has central routes and tenant-domain routes. Central operations include shared owner and pet records and the tenant registry. Clinic-specific operations include branches, appointments and visits. These are different data responsibilities within one application, not evidence of independently deployed services.
On tenant routes, the written middleware array is not the entire execution story. TenancyServiceProvider prepends tenancy middleware to Laravel's middleware priority list. Domain initialization therefore runs before authentication even where auth:sanctum appears earlier in the route declaration. Token lookup must happen against the intended connection; rearranging the route array without checking runtime priority can produce a misleading fix.
A useful verification exercises the real tenant hostname, initializes the expected storage, and authenticates with a token created in that context. Then repeat the request against another tenant domain and assert denial. Checking only whether a domain exists does not test the authentication boundary, and checking only the happy path misses accidental access to another organization's records.
Provisioning is a workflow, not one insert
The provider registers a TenantCreated pipeline: create storage, migrate it, seed it, create the main branch and replicate the owner. The source sets shouldBeQueued(false), so this pipeline is synchronous. Describing it as asynchronous onboarding would contradict the implementation.
The order captures real dependencies: tables must exist before their seed data, and initial clinic records must exist before the clinic can be used. It also introduces partial-failure cases. If a later step fails, a registry row or storage object may already exist. A database transaction in one connection cannot be assumed to reverse every provisioning operation across databases.
For production operation, I would require a documented readiness state and recovery procedure, with tests for failures after each material step. Those are recommendations, not a claim that the inspected pipeline implements a complete compensation system. The practical business benefit comes from knowing whether a clinic is ready, rather than treating a partially completed setup as a successful customer signup.
A queued job must establish its own context
RunAiAnalysis carries two identifiers: the tenant and the analysis. It does not assume that the worker inherited a browser request. The handler looks up the tenant in the central context, returns if it is missing, initializes tenancy and then looks up the analysis. The core processing and cleanup excerpt is:
try {
tenancy()->initialize($tenant);
$analysis = AiAnalysis::find($this->analysisId);
if (! $analysis) {
return;
}
$service->analyze($analysis);
} finally {
if (tenancy()->initialized) {
tenancy()->end();
}
}
This is a method-body excerpt from the real job, not a complete class. The earlier tenant lookup and dependency injection remain necessary. The finally block runs when processing returns or throws, preventing the job from leaving tenant context active for subsequent work. That is especially important in a reused worker process.
It does not establish exactly-once processing, automatic provider recovery or an appropriate retry policy. Those depend on the queue configuration and the analysis service. A missing tenant or analysis currently causes an early return; whether that should also record an operational event is a separate product decision.
Central records need authorization too
Storage isolation only protects records stored inside the selected tenant context. VetSpace also has central owner and pet records linked to clinics. The ScopesToClinic trait constrains queries through clinic_owner and clinic_pet associations. An empty clinic list is made to match nothing rather than remove the restriction.
This distinction matters: a central record can be globally stored yet visible only through an authorized clinic relationship. A review must check which controllers use the scoping mechanism, how the authenticated clinic is chosen, and how writes are authorized. The existence of a trait is not proof that every endpoint uses it correctly.
What to verify and when not to copy this
Before adopting the design, test tenant-domain authentication, cross-clinic reads and writes, a reused worker handling two tenants, partial provisioning failure, and recovery of one tenant from a backup. Exercise both schema-backed and database-backed tenants. A test that covers only the initial plan selection would miss the important existing-storage branch.
The business effect supported by the code is structural: one application can route work to distinct clinic contexts and reuse a defined provisioning sequence. No measured reduction in incidents, support hours or onboarding time is presented here. Such claims would require operational records and a defined baseline.
For a single organization's internal application, this machinery may be unnecessary. For a small SaaS with straightforward authorization and reporting, shared tables may be easier to operate. For stronger infrastructure separation, different databases on one server may still be insufficient. Start with ownership, recovery and access requirements, then choose the simplest model that satisfies them.
For implementation scope rather than architecture detail, see Laravel development. The first useful discussion is about the data boundary and failure cases, not the number of tenants a diagram appears to support.
Modernizing a PHP application incrementally: tests, content and releases in DigiSpace
A PHP upgrade is successful only if the application still does the work its users depend on. A green dependency installation does not prove that search finds the right records, a translated page keeps its address, or an uploaded image survives the next release. Modernization therefore begins with observable behaviour and operational constraints, not with replacing the framework.
DigiSpace provides a concrete example: a Laravel application with a Blade public site, database-backed localized content and a Filament administration panel. The repository also retains Inertia and Vue dependencies. That does not mean every admin screen uses the same stack. An accurate assessment traces the routes and views actually used instead of treating the dependency list as a complete architecture map.
The intended business result is a sequence of changes that can be checked independently. A search regression test protects catalogue behaviour; a content update preserves editorial fields it does not own; and a deployment check verifies the resulting page and asset. These controls make a change reviewable, but do not prove that every release is reversible.
Establish the version and integration baseline
The inspected DigiSpace manifest requires PHP ^8.3, Laravel ^13.0 and PHPUnit ^11.0. Those are dependency constraints, not a statement that every environment is running an identical patch version. Before an upgrade, record the installed packages, PHP runtime, extensions, database engine and worker configuration on the actual target.
Inventory the application's external boundaries too. DigiSpace includes storage, monitoring, CRM and reCAPTCHA dependencies. A runtime change may leave ordinary pages working while an outbound API request, uploaded object or scheduled command fails. The baseline should identify who owns each integration and how it can be exercised without sending real customer data or creating duplicate business records.
The official PHP 8.2-to-8.3 migration guide separates new features from incompatible and deprecated behaviour. It covers that particular version step. An application starting on an earlier PHP release needs the intervening guides as well. PHP 8.3 is the project's minimum constraint here, not a universal recommendation for a new system.
Compare three modernization approaches
An in-place upgrade keeps the current architecture and adjusts incompatible dependencies and code. It has the smallest product change when the structure still fits the business. Its limitation is that it may preserve coupling that makes subsequent features expensive. Version support and architecture quality are related concerns, but not the same task.
Incremental replacement introduces a boundary around one workflow and changes that workflow while the rest remains in use. Martin Fowler's Strangler Fig explanation describes this approach and its transitional cost. Temporary adapters and routing rules are real work; they need ownership and eventual removal rather than becoming another permanent layer.
A full rewrite may make sense when the product or data model is fundamentally changing. It also creates a large acceptance and migration problem: undocumented behaviour must be discovered, old and new systems may need parallel support, and data must cross a clearly defined cutover. None of those obligations disappear because the replacement uses a newer framework.
Start with a real user-visible boundary
DigiSpace's service search is a useful bounded example. The application stores both categorized catalogue services and internal feature rows associated with pricing packages. Search should return the former when they match. It should not advertise a feature row that has no public service category.
HeaderSearchTest captures that distinction. The following is the actual test method, formatted across lines for readability. It belongs to the repository's test class, which imports the two models, uses RefreshDatabase and SeedsPublicSite, and calls seedPublicSite() during setup. It is not a standalone PHP script:
public function test_service_search_lists_matching_categorised_services(): void
{
$category = ServiceCategory::create(['name' => 'Web', 'slug' => 'web']);
Service::create([
'title' => 'Laravel development',
'slug' => 'laravel-development',
'status' => 'active',
'service_category_id' => $category->id,
'description' => 'Backend'
]);
Service::create([
'title' => 'Orphaned laravel service',
'slug' => 'orphan',
'service_category_id' => null,
'description' => 'no category'
]);
$this->get('/uk/service-search?search=laravel')
->assertOk()
->assertSee('Laravel development')
->assertDontSee('Orphaned laravel service');
}
The route is /uk/service-search, with the query parameter search. It is not /en/services/search. The records use Service::create(); this example does not invent a factory that the model does not provide. These details determine whether a reader can reproduce the behaviour in this repository.
The controller applies both an active-status condition and whereHas('serviceCategory'). This test verifies a particular rendered outcome under its fixtures. To prove category filtering independently of every other condition, an additional fixture should explicitly mark the orphaned row active. Separate tests should cover an empty query, no matches and localized content. A single passing example must not be described as complete search coverage.
Keep test isolation separate from application data
RefreshDatabase can reset schema in the resolved test database. Do not run the example against a populated local application or production database. Check the effective environment, connection, hostname and database name first; a cached Laravel configuration can undermine assumptions about which environment variables are in use.
DigiSpace uses MySQL-specific behaviour elsewhere in its public-site queries, so replacing MySQL with SQLite merely to simplify a run can hide compatibility issues. Use a dedicated MySQL test database for the repository's database-backed suite, with fake external integrations where needed. Any reset or cleanup should be scoped to that verified test target.
Once those prerequisites are satisfied, the focused suite target is tests/Feature/HeaderSearchTest.php. The expected outcome is not just HTTP 200: the matching catalogue title appears and the orphaned title does not. A failed assertion should lead back to routing, fixtures or query conditions rather than immediately weakening the test.
Separate code changes from editorial data
A repository can contain seed content without owning every current database value. DigiSpace's JSON-backed fresh-install seeders insert rows, whereas StructureTranslationsSeeder matches existing records and fills missing translations. Existing non-empty editorial translations win. Neither mechanism is a general-purpose publisher for a revised article.
This distinction explains why deploying an updated JSON file may leave a live page unchanged. Conversely, replaying fresh-install seeders into a populated database can fail or affect unrelated state. A targeted content change should identify records by stable slug, restrict the fields it changes, preserve authors and images unless explicitly in scope, and take a recoverable snapshot beforehand.
For modernization, the same rule applies to migrations: know which data a change owns. Avoid combining a runtime upgrade, a schema redesign and broad content replacement in one release. When a result is wrong, smaller changes make it possible to identify which operation introduced the difference.
Check deployment results, not just build results
DigiSpace's deployment hook runs migrations, publishes Livewire assets, rebuilds configuration and view caches, and generates the sitemap. That is concrete repository behaviour. It is not proof that every migration can be undone, or that restoring a previous code directory restores database content as well.
Define code rollback and data recovery separately. A previous release may no longer understand a changed schema. A database restore may discard edits made after the backup. For schema evolution, prefer compatible staged changes when feasible; for content, preserve a field-level snapshot and check for intervening edits before restoring it.
Public assets need an independent check. A correct og:image tag can still point to a missing file. Verify the HTTP result, content type, image dimensions and visible image rather than treating a metadata assertion as a complete social-preview test. For localized pages, inspect canonical links and actual navigation destinations as well as translated headings.
What this proves, and when not to copy it
The repository gives evidence of a targeted search test, explicit public query constraints and separate deployment and translation mechanisms. It does not provide a measured before-and-after reduction in outages or development cost. The defensible benefit is narrower: important behaviours can be named and checked instead of being rediscovered manually after each change.
Do not turn incremental modernization into endless patching when the underlying product is being replaced. Do not introduce a service layer around every short function without a concrete boundary to protect. And do not begin a broad upgrade when backups, runtime visibility and an isolated verification environment are still missing.
For a bounded engagement, see PHP development and modernization. Start with one workflow, its expected result and its failure conditions. That creates a useful first release and a better basis for deciding whether the next step is maintenance, extraction or replacement.
Categories
Latest Posts
Archive