A PHP queue worker wraps each job in catch (Exception $e), yet a TypeError from a library call kills the whole worker; why, and how should the job boundary treat each branch?
answer
- TypeError is on the Error branch
- uncaught throwable ends the process
- catch Throwable at the job boundary
- Errors are deterministic: do not retry
- some fatals are not throwables at all
basics
~10 sTypeError extends Error, not Exception, so catch (Exception) lets it escape and the uncaught throwable ends the CLI worker. Catch Throwable at the job boundary, retry only transient Exceptions, and fail Errors as bugs.
solid answer
~50 sThe worker's safety net only matches the `Exception` branch. A library call that receives a wrong type, a bad value or the wrong number of arguments throws `TypeError`, `ValueError` or `ArgumentCountError` — all on the `Error` branch — so the throwable leaves the loop, becomes `Fatal error: Uncaught TypeError`, and the CLI process exits with status 255. The fix is a `catch (Throwable $t)` at the **job boundary**, with branch-aware handling: `Exception` subclasses such as `PDOException` or a timeout may be transient and worth a retry with backoff; `Error` subclasses are **deterministic** — the same input fails the same way — so mark the job failed, log it with the class and trace, and move on. Keep narrower catches inside the job for the failures it can actually handle. Some fatal conditions, like exhausting `memory_limit`, are not throwables and still need a process supervisor.
code
php · 17 lines<?php
declare(strict_types=1);
while ($job = $queue->reserve()) {
try {
$handler->handle($job);
$queue->ack($job);
} catch (Error $e) { // TypeError, ValueError, ...: a bug
$logger->critical('job crashed', ['class' => $e::class, 'msg' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
$queue->fail($job); // never retried
} catch (Exception $e) { // operational failure
$job->attempts() < 5
? $queue->release($job, delay: 30 * $job->attempts())
: $queue->fail($job);
$logger->warning('job failed', ['class' => $e::class, 'msg' => $e->getMessage()]);
}
}go deeper
Recognise that catch (Exception) does not catch TypeError and that an uncaught throwable stops a PHP script.
Explain which PHP 8 failures sit on the Error branch and write a boundary that catches Throwable, or Error and Exception separately.
Design the worker's failure policy: branch-aware retries with caps, failed-job storage, structured logs, process recycling, and a supervisor for fatal errors.
Set the organisation's policy for long-running PHP processes, deciding which failures page someone, which are retried, and how poisoned messages are quarantined.
## The scenario A long-running PHP CLI worker pulls jobs from a queue in a loop: ```php while ($job = $queue->reserve()) { try { $handler->handle($job); $queue->ack($job); } catch (Exception $e) { $logger->error($e->getMessage()); $queue->release($job, delay: 30); } } ``` After a dependency upgrade, a library method gained a declared parameter type and one job passes a value that does not fit. The worker process dies; the supervisor restarts it; it picks up the same job and dies again. ## Why the catch did not fire - The library call throws `TypeError`. - `TypeError` extends `Error`, and `Error` and `Exception` are **separate branches** under `Throwable`. - `catch (Exception $e)` therefore does not match; the throwable leaves the loop. - With nothing else to catch it, PHP reports `Fatal error: Uncaught TypeError: ...` and the CLI process exits with status **255**. The same happens for `ValueError` (right type, bad value), `ArgumentCountError`, `DivisionByZeroError` and the other `Error` subclasses PHP 8 throws where PHP 7 used to warn. ## Designing the job boundary A worker loop is exactly the kind of **boundary** where catching `Throwable` is correct: one job's failure must not take down the process. But what you do next should depend on the branch. | Throwable caught | Typical cause | Retry? | Action | |---|---|---|---| | `Exception` subclass for an operational failure (`PDOException`, a timeout, an HTTP 503) | transient condition | yes, with backoff and a limit | release with delay | | domain `Exception` (`OrderNotFound`) | data state | usually no | mark failed or skip | | `Error` subclass (`TypeError`, `ValueError`, `ArgumentCountError`) | bug in code or bad payload | no — deterministic | move to failed-job storage, alert | 1. **Catch `Throwable` once, at the loop.** Inside the handler, keep narrow catches for failures it can handle. 2. **Classify before retrying.** Retrying a `TypeError` repeats the same failure; with no limit, a single poisoned job loops forever. 3. **Log the class, message, file, line and trace.** `get_class($t)` distinguishes a `TypeError` bug from a `PDOException` incident at a glance. 4. **Cap attempts** for everything, even retryable exceptions, and route exhausted jobs to a failed-job store. 5. **Keep the process healthy.** After an `Error`, state inside long-lived services may be inconsistent; many workers exit after N jobs or on specific failures and let the supervisor start a fresh process. ## What the boundary still cannot catch - Exhausting `memory_limit` or hitting `max_execution_time` is a fatal error, **not** a `Throwable`; no `catch` sees it. - A `ParseError` in a file included during the job **is** a throwable and can be caught, but it signals a broken deploy. So a process supervisor that restarts crashed workers remains necessary even with a perfect `catch (Throwable)`. ## Testing the boundary A worker's failure policy is code and deserves tests: 1. a handler that throws `TypeError` — the job must be marked failed, not released, and the loop must continue; 2. a handler that throws a transient `Exception` — the job must be released with a delay until the attempt cap, then failed; 3. a handler that succeeds after one transient failure — it must be acknowledged exactly once; 4. the log records for each case must carry the throwable's class. These tests catch the regression where someone narrows the boundary back to `catch (Exception $e)`. ## Preventing the TypeError itself - Validate payloads at the queue boundary so malformed data fails with a clear domain exception before reaching the library. - Run static analysis in CI so type mismatches against a library's new signatures are found before deploy. - Pin and review dependency upgrades that add parameter types, since they convert previously tolerated calls into `TypeError`s.
- Why not simply retry every failed PHP job a few times, whatever it threw?`Error` subclasses such as `TypeError` or `ValueError` come from the code and the payload, so the same job fails identically each time; retries waste capacity and delay the alert. Only failures that can change between attempts, typically operational `Exception`s, benefit from backoff.
- Why is catch (Throwable) acceptable in a worker loop but discouraged deep inside business logic?At the loop, the job is the unit of failure and the process must survive, so catching everything to log and fail one job is the right trade. Deep inside, catching `Throwable` swallows programming errors the code cannot fix, hiding bugs behind a generic fallback.
saying these in an interview costs you the question
- catch (Exception $e) around a job catches every PHP 8 failure.
- Retrying a job that threw TypeError will eventually succeed.
- catch (Throwable $t) also catches exhausting memory_limit.
- An uncaught TypeError in a CLI worker is logged and the loop continues.
- Errors should be caught and ignored so the worker keeps its throughput.