---
title: "Timeouts & error handling — Alerts for Laravel | Roundly"
description: "Bound checks with per-monitor timeouts, rely on exception-safe runs, and know every exception the package can throw."
url: https://roundly-consulting.com/open-source/docs/alerts-for-laravel/timeouts-and-errors
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Alerts for Laravel](https://roundly-consulting.com/open-source/docs/alerts-for-laravel.md)

# Timeouts & error handling

## Per-check timeout

timeout(5) bounds a check at five seconds:

```php
Health::for($team)->monitor(PaymentGatewayCheck::class)
    ->everyMinute()
    ->timeout(5)   // hard abort under CLI with ext-pcntl, post-hoc report elsewhere
    ->save();
```

On the CLI with ext-pcntl — queue workers included — the check is hard-aborted the moment the budget elapses, via SIGALRM. Elsewhere (web SAPI, Windows) it is a best-effort post-hoc report: the check runs to completion and is marked failed if it overran. A timed-out check flows through the normal failed-result path with a CheckTimedOut exception recorded in meta, plus timed\_out\_after.

The alarm aborts the check by throwing CheckTimedOut into it, so a broad catch (Throwable) inside the check — every built-in check has one — could swallow it. The timeout is still reported: once the alarm has fired, a check that returns normally or throws something else ends as a timed-out failed result, with timed\_out\_after in its meta. A check that keeps retrying is not aborted twice, so it can still run past its budget before that result is recorded.

Inside a queue worker the job’s own --timeout alarm is kept: a check budget longer than the time the job has left never extends it, and the worker’s alarm is re-armed after the check.

## Exception-safe checks

A check’s check() never needs a try/catch. Any thrown exception is converted into a clean failed result and recorded as a normal alert and run, so a bad check never fails the queue job:

```php
Health::define('flaky', function () {
    throw new RuntimeException('upstream exploded'); // becomes CheckResult::failed()
});

// Or build a result from a caught exception yourself:
CheckResult::fromException($e); // status failed; exception class, message and 15 stack frames in meta
```

## Exception details stay in your logs

An exception’s message and stack frames can carry hosts, SQL and credentials, so by default they are neither stored nor mailed. The run and the alert get the message “The check threw RuntimeException; the details are in the application log.” — the alerts::checks.check\_errored line, in English and Slovak — and keep only meta\['exception'\], the class. The throwable itself is reported to your exception handler, so it lands in your logs. The CheckResult a run-now returns still carries everything:

```php
$result = Health::for($team)->run('flaky');

$result->meta['exception_message'];  // 'upstream exploded' — the caller of a run-now gets everything
$result->storedMessage();            // the message as stored and mailed (≤ CheckResult::MAX_STORED_MESSAGE_LENGTH, 1000)
$result->storedMeta();               // the meta as stored on runs and alerts

$alert = $team->alerts()->open()->first();

$alert->message;                     // 'The check threw RuntimeException; the details are in the application log.'
$alert->meta['exception'];           // 'RuntimeException' — the class is always stored
$alert->meta['timed_out_after'];     // seconds — present only for timeouts
```

- Stored: a check’s own message, the package’s timeout text, meta\['exception'\] and timed\_out\_after. Left out: exception\_message, exception\_trace and the error a built-in check caught.
- A timeout is not reported to the exception handler — its text is the package’s own and is stored. Built-in checks report the error they catch, whatever store\_exception\_details says.
- storedMessage() and storedMeta() return exactly what is stored on runs and alerts — and mailed by the bundled notification.
- A custom check that catches its own exception decides what it returns: whatever it puts in its message or meta is stored. Let it throw to get the treatment above.
- To store and mail the details as before 1.2, set store\_exception\_details (ALERTS\_STORE\_EXCEPTION\_DETAILS=true, a strict boolean): the exception’s message — up to 1000 characters in the message columns, in full in meta\['exception\_message'\] — and its first 15 stack frames in meta\['exception\_trace'\].

Only check() itself is guarded. Resolving the check (an unregistered key) or the owner (no HasNotifiablesForAlerts) still throws and fails the job — those are configuration errors you want to see.

## Exceptions

All live in RoundlyConsulting\\Alerts\\Exceptions, apart from the toolkit one:

| Exception | Thrown when |
| --- | --- |
| `CheckTimedOut` | A check overruns its timeout() — also when the check catches it itself. Caught by the run and stored as a failed result — it never escapes. Carries public key and seconds. |
| `InvalidCronExpression` | malformed(): a schedule is saved — monitor()->save(), schedule() or the trait shortcuts — with an expression that is not a valid 5-field cron or supported alias; nothing is stored. skipped(): runDue() met a stored row it cannot evaluate (written by a seeder or by hand) — reported to your exception handler, not thrown, and the rows after it are still dispatched. |
| `InvalidHealthCheck` | doesntExtendBaseCheck(): a class that does not extend Check is registered, run or scheduled. notRegistered(): a scheduled row names, or run() is given, a key that is not registered in the running process. ambiguous(): run() is given a class registered under several keys with as() — run it by key. invalidEscalationThreshold(): an escalation policy keyed by anything but a whole number of at least 1 — 0, a negative number, a name or a list-style \['owner', 'team'\]. invalidIdentity(): a uniqueBy() meta key holds a value that is not a scalar or null — the message names the key, never the value. notScheduledFor(): Health::for($owner)->run(), unmonitor() or reconfigure() is given a row that belongs to another owner. |
| `InvalidRetention` | days(): Health::prune() is given fewer than 1 day — it would delete the whole run history. silenceDays(): Health::silences()->prune() is given fewer than 1 day. An InvalidArgumentException; nothing is deleted. |
| `ScheduleConflict` | reconfigure() would move a uniqueBy() schedule onto the identity another live schedule of the check holds. A final RuntimeException naming the check, the row and the identity’s meta keys — never their values; nothing changes. Map it to 409 Conflict. |
| `InvalidNotifiableForHealthCheck` | The row’s owner does not implement HasNotifiablesForAlerts, or no longer exists. |
| `InvalidConfigurationException` | From package-toolkit: a config value is invalid — a model key that is not the packaged model or a subclass of it, an unknown key\_type, a switch that is not a boolean, an unknown schedule.frequency, a non-string route.uri or route.name, or a history.retention\_days or silence-retention that is not a whole number of at least 1. The message names the key. |

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/alerts-for-laravel.md)
