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.