skip to content

A PHP import script run by cron ends with exit('Import failed') on errors, yet the scheduler records success; why, and how should it report failure?

level: seniorimportance: should knowfreq 32%

answer

  1. a string status prints, then exits 0
  2. integers 0-254 for your codes
  3. 255 means a fatal error
  4. exit() is a function since PHP 8.4
  5. finally blocks do not run on exit

basics

~20 s

exit() with a string prints it and ends with status 0, meaning success. Write the message to STDERR and exit() with an integer: 0 for success, 1-254 for failures; PHP itself uses 255 for fatal errors and uncaught exceptions.

solid answer

~40 s

`exit(string|int $status = 0): never` has two modes. An `int` becomes the process exit status; a `string` is printed to standard output and the status is `0`. So `exit('Import failed')` tells cron, CI and `&&` chains that everything worked. The fix is `fwrite(STDERR, "Import failed\n"); exit(1);`. Keep your codes in 0-254: PHP itself ends with 255 on a fatal error or an uncaught exception, so 255 means "PHP crashed", and the operating system keeps only the low eight bits. Since PHP 8.4 `exit()` behaves like a function, so under `strict_types` a wrong type throws a `TypeError`. `exit()` runs shutdown functions and destructors but not `finally` blocks, so cleanup that must happen belongs in those.

code

php · 20 lines
php
<?php
declare(strict_types=1);

function main(array $argv): int
{
    $file = $argv[1] ?? null;
    if ($file === null) {
        fwrite(STDERR, "usage: {$argv[0]} <file.csv>\n");
        return 2;
    }
    try {
        $failed = importFile($file); // returns the number of rejected rows
    } catch (RuntimeException $e) {
        fwrite(STDERR, 'Import failed: ' . $e->getMessage() . "\n");
        return 3;
    }
    return $failed > 0 ? 1 : 0;
}

exit(main($argv)); // one integer exit point

go deeper

for a junior

Recall that an integer passed to exit() is the status, 0 means success, and a string passed to exit() is printed while the status stays 0.

for a middle

Explain which codes PHP produces on its own (255 for fatals and uncaught exceptions), why scripts keep to 0-254, and what the 8.4 exit-as-function change altered.

for a senior

Diagnose silent cron failures and design commands with one integer exit point, documented codes, messages on STDERR and cleanup that does not depend on finally after exit().

for a principal

Standardise exit-code meanings across the team's commands so schedulers, CI and alerting can react to them without parsing text output.

## Exit status: the one number the caller sees When a process ends, it hands its parent an **exit status**, a small integer. Shells, cron wrappers, CI runners and process managers read it: - `0` means success; - anything else means failure, and specific values can say which failure. `php import.php && php notify.php` runs the second command only if the first returned `0`. A CI step fails only on a non-zero status. A command that prints an error but returns `0` is, to every tool, a success. ## `exit()` in PHP 8.5 The signature in `Zend/zend_builtin_functions.stub.php` is `exit(string|int $status = 0): never` (`die()` is an alias). It behaves in two ways: | Call | Printed | Exit status | |---|---|---| | `exit;` or `exit()` | nothing | `0` | | `exit(3)` | nothing | `3` | | `exit('Import failed')` | `Import failed` on standard output | `0` | | `exit('1')` | `1` on standard output | `0` — a string, even a numeric one, is printed | That third row is the bug in the question: the message is printed, the status is success, and the scheduler has no reason to alert anyone. The message even goes to standard output, where it can pollute data. ## Reporting failure correctly 1. Write the human message to `STDERR`: `fwrite(STDERR, "Import failed: {$reason}\n");` 2. End with an integer: `exit(1);` 3. Optionally use distinct codes for distinct failures, and document them: - `0` — imported (or, with `--dry-run`, planned) successfully; - `1` — some rows failed validation; - `2` — bad usage, such as an invalid `--limit`; - `3` — the source file could not be read. Keep codes in **0-254**. The manual reserves `255` for PHP, and the operating system keeps only the low eight bits of the status, so values above 255 wrap around (256 becomes 0). ## What PHP does on its own - **Normal end of script:** status `0`, unless an earlier `exit()` set another. - **Fatal error** (`E_ERROR`, parse errors and similar): status `255`. - **Uncaught exception:** reported as a fatal error, so also `255`. - **`php -l` finding a syntax error:** `255`. A well-behaved command catches its expected failures and chooses its own code; a `255` in the logs then reliably means something unexpected crashed. ## What runs when you call `exit()` - Shutdown functions registered with `register_shutdown_function()` run. - Object destructors run. - **`finally` blocks do not run.** `exit()` unwinds the script without executing them, so a `try { ... exit(1); } finally { unlock(); }` never unlocks. Put cleanup that must always happen in a shutdown function or a destructor, or return an exit code from the main function and call `exit()` once, at the very end, outside any `try`. ## The PHP 8.4 change Before PHP 8.4, `exit` was a pure language construct: any non-integer argument was cast to string and printed. Since 8.4 it behaves like a real function: - it follows normal type coercion and respects `declare(strict_types=1)`; - an invalid argument type (an array, an object) throws a `TypeError` instead of being printed; - it can be called with a named argument (`exit(status: 1)`) and used as a callable. It still has its own parser token, so `exit;` without parentheses keeps working. ## A reliable shape for commands - `function main(array $argv): int` returns the code; - expected failures are caught inside `main()` and mapped to codes, with messages on `STDERR`; - the file ends with `exit(main($argv));` — a single, integer exit point.

  • Why does a finally block that releases a lock not run after exit(1)?
    `exit()` terminates the script without executing pending `finally` blocks; only shutdown functions and destructors run. Return the exit code from your main function so the `try`/`finally` completes normally, then call `exit()` once at the top level, or release the lock in a destructor or shutdown function.
  • The scheduler reports exit status 255 for the import. What does that suggest?
    PHP uses 255 when it stops on a fatal error or an uncaught exception, which the manual reserves for PHP itself. If the script's own codes stay within 0-254, a 255 means something unexpected crashed — check the error log rather than the script's own failure paths.

saying these in an interview costs you the question

  • exit('Import failed') makes the script return a failure status
  • exit('1') exits with status 1
  • Exit code 255 is a good choice for a script's own errors
  • finally blocks always run, even after exit()
  • An uncaught exception ends a CLI script with status 1