Symfony is the framework of long-lived PHP applications: intranets, e-commerce platforms, APIs that outlast three redesigns of the frontend. Its architecture is famously explicit — and that explicitness extends to monitoring, because Symfony ships no health endpoint, no built-in scheduler daemon, and a worker model (Messenger) that is designed to die and be restarted. Every one of those design choices is sound, and every one of them creates a silent failure mode when the surrounding infrastructure isn't holding up its end.

This guide walks through monitoring a Symfony application the way it actually breaks: a health check controller worth pointing a monitor at, dead Messenger workers, Scheduler tasks that stop without an error, PHP-FPM pool exhaustion, and the deploy-time traps. As always, CronAlert is agentless — everything here is plain HTTP checks, content assertions, and heartbeats. No bundle to install, though the health controller below is twenty lines of one.

Write a health check controller

Symfony gives you the pieces — a controller, the Doctrine DBAL connection, your cache pool — and the decisions come from our health check endpoints guide: check what the app is useless without, keep it fast, and never leak internals in the response.

#[Route('/healthz', name: 'healthz')]
public function healthz(Connection $db, CacheItemPoolInterface $cache): JsonResponse
{
    try {
        $db->executeQuery('SELECT 1');

        $probe = $cache->getItem('healthz_probe');
        $probe->set(time())->expiresAfter(60);
        $cache->save($probe);
    } catch (\Throwable $e) {
        // Log the detail; never send it to the caller
        $this->logger->error('healthz failed', ['error' => $e->getMessage()]);

        return new JsonResponse(['status' => 'down'], 503);
    }

    return new JsonResponse(['status' => 'ok']);
}

Three Symfony-specific details: exclude the route from your security firewall (access_control allowing anonymous access, or a dedicated firewall with security: false); exclude it from any maintenance-mode listener, so the monitor can tell "deploying" apart from "down"; and set a short connect_timeout on the DBAL connection used here so the health check can't hang longer than your monitor's timeout. Point the monitor at the JSON and add a keyword assertion on "status":"ok" — a proxy's cached 200 error page will never contain it. The database health endpoint guide covers deeper probes than SELECT 1.

Messenger workers: designed to die, expected to be restarted

Symfony Messenger's contract is explicit: messenger:consume is a mortal process. It exits after --limit, --time-limit, or --memory-limit, on unrecoverable transport errors, and on deploys — and Supervisor or systemd is supposed to restart it. The silent failure isn't the worker dying; it's the restarter failing: a Supervisor config not reloaded after a deploy, a systemd unit left disabled, a container orchestrator scaled to zero. HTTP stays green; the async lane — emails, invoices, search indexing — quietly backs up in the transport.

Nothing in the request path will ever notice, which makes this the canonical background worker monitoring problem. Two patterns work:

  • Canary message. A scheduled task dispatches a trivial message every N minutes whose handler pings a heartbeat URL. Transport backed up or worker dead → no ping → alert. This tests the whole pipeline, not just the process table.
  • Failed-transport watch. Messages that exhaust retries land in the failure transport. A scheduled command that counts them and refuses to ping its heartbeat when the count grows turns "the failure queue is filling" into silence — and silence into an alert.

Scheduler: when the schedule itself can die

Symfony's Scheduler component (6.3+) is elegant — recurring tasks defined in PHP, no crontab — but it executes inside a messenger:consume worker running the scheduler transport. That concentration is the risk: one dead worker stops every scheduled task at once, with no error anywhere, because nothing is failing — nothing is running. Classic crontab setups fail similarly for one entry at a time (a bad deploy, a typo'd crontab, a renamed command).

The fix is the same heartbeat pattern as everywhere else: each critical task pings a unique URL only after verified success, and CronAlert alerts when the ping stops. For console commands, a listener makes it automatic:

// Ping only when the command exits 0 — silence is the alarm
#[AsEventListener(event: ConsoleEvents::TERMINATE)]
public function onTerminate(ConsoleTerminateEvent $event): void
{
    if ($event->getCommand()?->getName() !== 'app:export:nightly') {
        return;
    }
    if ($event->getExitCode() === 0) {
        $this->httpClient->request('GET',
            'https://cronalert.com/api/heartbeat/TOKEN');
    }
}

For plain crontab entries, && curl -fsS https://cronalert.com/api/heartbeat/TOKEN at the end of the line does the same job. One heartbeat per critical task; grace period sized to the schedule. If your schedules run under systemd timers or Kubernetes CronJobs instead, our CronJob guide covers that variant of the same disease.

What to monitor in a Symfony app

  • The homepage or API root — whatever real users hit first, with an assertion on expected content, not just a 200.
  • Your /healthz — the dependency-aware signal, with a keyword assertion on "status":"ok".
  • The two or three routes the business depends on — checkout, login, the main API resource; see API endpoint monitoring for assertion patterns.
  • HTTPS and the SSL certificate — certbot renewals fail more often than certificates should; see SSL certificate monitoring.
  • A heartbeat per Messenger worker fleet and per critical scheduled task — the request path can't see either.

The HTTP and SSL checks fit inside CronAlert's free plan (25 monitors, 3-minute interval); Pro ($5/mo) adds the 1-minute interval, keyword assertions, and heartbeats.

Symfony-specific failure modes

  • PHP-FPM pool exhaustion. All pm.max_children workers stuck on slow queries or an API call without a timeout; new requests queue at the proxy until 502/504. It builds gradually and recovers on its own — the signature of a problem only a response-time threshold catches in the act. Our thresholds guide covers picking the numbers, and the same pattern appears in our Laravel guide — it's PHP's version of thread-pool starvation.
  • Deploy half-states. cache:clear not run, a stale container image, composer install --no-dev skipped, or APP_ENV=dev leaking to production — the app "works" while serving the debug toolbar or stale container definitions. A content-hash check on a stable endpoint catches a deploy that changed what it shouldn't (or didn't change what it should).
  • Doctrine connection exhaustion. Long-running workers holding connections, or SET SESSION-heavy code pinning them; the health check's SELECT 1 slows, then fails. The stall phase is visible to a response-time threshold well before the errors.
  • The 200-that-lies. An error page rendered by a custom exception listener with the wrong status code, a maintenance page returning 200, or FastCGI's default of masking upstream errors. Keyword assertions on real content are the antidote — the recurring lesson of this whole series: status codes lie.
  • OPcache/realpath cache after deploys. Symlink-swap deploys with OPcache misconfigured serve a Frankenstein of old and new code — errors that look impossible in either version. A smoke-test monitor on a version endpoint (assert the new release hash) turns "deployed" into a verified claim.

A concrete CronAlert setup

  • Ship /healthz — the controller above, outside the firewall, with DBAL and cache probes.
  • HTTP monitor on it — expect 200 (3-minute interval free; 1-minute on Pro), keyword assertion on "status":"ok" (Pro).
  • Monitors on key routes — with content assertions that only a correct response satisfies.
  • A heartbeat per critical schedule and worker fleet (Pro) — the ConsoleEvents::TERMINATE listener or a && curl crontab suffix; canary message for Messenger.
  • SSL and response-time checks — certificate expiry on the domain; thresholds on /healthz and checkout as your FPM-exhaustion early warning.
  • Alert channels — email, Slack, Discord, Teams, Telegram, PagerDuty, Opsgenie, Splunk On-Call, webhooks, or PWA push.

Every plan includes the REST API (read-only on free, read-write from Pro) for managing monitors from your deploy pipeline, and CronAlert ships an MCP server so Claude Code, Cursor, or Windsurf can manage monitors conversationally. Create a free account and the first monitor takes about a minute.

Frequently asked questions

Does Symfony have a built-in health check endpoint?

No — write a small controller that probes Doctrine and your cache, returns 200/503 without internals, and sits outside the firewall. Twenty lines, shown above.

Why did my Symfony Messenger worker stop processing messages?

Workers are mortal by design; the failure is the restarter (Supervisor/systemd) not bringing them back. HTTP stays green while the transport backs up. A canary message whose handler pings a heartbeat catches the whole pipeline.

Why aren't my Symfony Scheduler tasks running?

Scheduler runs inside a messenger:consume worker — if that worker is dead or never started after a deploy, every task stops with no error. Heartbeat pings on each critical task turn the silence into an alert.

Why does my Symfony site hang under load?

Usually PHP-FPM pool exhaustion — all workers stuck on slow I/O, requests queueing at the proxy. A response-time threshold on your health endpoint fires during the ramp, before the 502s.

How do I monitor Symfony cron jobs and commands?

Heartbeat per command: ping a unique URL only on success — via && curl in the crontab or a ConsoleEvents::TERMINATE listener checking the exit code. Silence beyond the grace period alerts.

Start monitoring your Symfony app

Symfony's failures are quiet ones: a worker nobody restarted, a schedule that stopped scheduling, an FPM pool that filled up and drained before you could look. A twenty-line /healthz, content assertions on the routes that matter, heartbeats on workers and scheduled tasks, and SSL and response-time checks give you a signal you can trust — HTTP and SSL checks on the free plan, assertions and heartbeats from $5/mo. Create a free CronAlert account and point the first check at /healthz in the next five minutes.

Related reading: HTTP health check endpoints, background worker monitoring, cron job heartbeat monitoring, and our companion guides for Laravel, WordPress, Django, Rails, FastAPI, Express/Node.js, Spring Boot, Go, and ASP.NET Core applications.