---
title: "Exceptions & retry-after — Package Toolkit for Laravel | Roundly"
description: "The toolkit’s exception hierarchy with its exact messages, plus the HasRetryAfter contract for rate-limit errors."
url: https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel/exceptions
language: en
---

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

[Package Toolkit for Laravel](https://roundly-consulting.com/open-source/docs/package-toolkit-for-laravel.md)

# Exceptions & retry-after

Everything the toolkit throws extends one base, so you catch any toolkit failure in one place — or the specific subclass:

- PackageToolkitException — the abstract base for everything the toolkit throws (a RuntimeException).
- InvalidConfigurationException — a config value is missing, of the wrong type, out of range, not one of an enum’s cases, not a valid list item, or not a model class; also thrown by DatabaseDriver::current() for a driver the enum does not model.

```php
use RoundlyConsulting\PackageToolkit\Exceptions\InvalidConfigurationException;
use RoundlyConsulting\PackageToolkit\Exceptions\PackageToolkitException;
use RoundlyConsulting\PackageToolkit\Support\Config;
use RoundlyConsulting\PackageToolkit\Support\ModelResolver;

try {
    $perPage = Config::integer('comments.per_page', 20, min: 1, max: 100);
} catch (InvalidConfigurationException $e) {
    // Configuration value [comments.per_page] must be between 1 and 100, [500] given.
}

try {
    $class = ModelResolver::for('comments.models.comment', Comment::class);
} catch (PackageToolkitException $e) {
    // any toolkit failure
}
```

## Messages

Each failure has a named factory and a fixed message, so logs stay greppable. Every “wrong value” message has one shape — Configuration value \[key\] must be …, \[given\] given. (must contain … for a list item) — where given is the offending value, or for the two list-item factories the offending item, as written ('' when empty), an int, float or bool as its PHP literal (500, 1.5, true), and anything else by its type (array, null):

| Factory | Message |
| --- | --- |
| `missing($key)` | Configuration value \[{key}\] is required but missing. |
| `notAString($key, $value)` | Configuration value \[{key}\] must be a non-empty string, \[{given}\] given. |
| `notAnInteger($key, $value)` | Configuration value \[{key}\] must be an integer, \[{given}\] given. |
| `notAFloat($key, $value)` | Configuration value \[{key}\] must be a decimal number, \[{given}\] given. |
| `outOfRange($key, $min, $max, $value)` | Configuration value \[{key}\] must be between {min} and {max}, \[{given}\] given. |
| `notAList($key, $value)` | Configuration value \[{key}\] must be a list of strings (an array or a comma-separated string), \[{given}\] given. |
| `notAStringItem($key, $item)` | Configuration value \[{key}\] must contain only string items, \[{item}\] given. |
| `notAValidListItem($key, $item)` | Configuration value \[{key}\] must contain only valid items, \[{item}\] given. |
| `notABoolean($key, $value)` | Configuration value \[{key}\] must be a boolean (true/false, 1/0, on/off or yes/no), \[{given}\] given. |
| `notAValidEnum($key, $enum, $value)` | Configuration value \[{key}\] must be one of \[{values}\], \[{given}\] given. |
| `notOneOf($key, $allowed, $value)` | Configuration value \[{key}\] must be one of \[{allowed}\], \[{given}\] given. |
| `notAKeyType($key, $value)` | Configuration value \[{key}\] must be one of \[bigint, uuid, ulid\] (case-insensitive), \[{given}\] given. |
| `notAnImplementation($key, $contract, $value)` | Configuration value \[{key}\] must be a class-string of \[{contract}\], \[{given}\] given. |
| `notAModel($key, $value, $base)` | Configuration value \[{key}\] must be a class-string of \[{base}\], \[{given}\] given. |
| `unsupportedDatabaseDriver($driver)` | Unsupported database driver \[{driver}\]. |

With only one bound, outOfRange() reads “must be at least {min}” or “must be at most {max}” instead of “between”. It covers both integer() and float().

A value wrapped in PHP’s SensitiveParameterValue is described by the type of what it wraps (\[int\] given., \[string\] given.), never its content — the secret readers pass every value that way. Every value parameter ($value, $item) is #\[SensitiveParameter\], so the message is the only place a value shows, never the arguments of a stack frame. See Secrets in config and facades.

## Retry-after hints

HasRetryAfter is a contract for exceptions (or other signals) that carry a retry-after hint, so a host can translate a rate-limit failure into a Retry-After header without coupling to any particular base exception. The ProvidesRetryAfter trait implements it: withRetryAfter() stores the delay in seconds, clamped to zero or more, and returns the exception; retryAfterSeconds() reads it back and defaults to 0:

```php
use RoundlyConsulting\PackageToolkit\Concerns\ProvidesRetryAfter;
use RoundlyConsulting\PackageToolkit\Contracts\HasRetryAfter;
use RuntimeException;

final class TooManyRequestsException extends RuntimeException implements HasRetryAfter
{
    use ProvidesRetryAfter;
}

$e = (new TooManyRequestsException('Rate limit reached.'))->withRetryAfter(30);
$e->retryAfterSeconds();                                                 // 30

(new TooManyRequestsException)->retryAfterSeconds();                     // 0 — the default
(new TooManyRequestsException)->withRetryAfter(-5)->retryAfterSeconds(); // 0 — clamped

throw $e;
```

```php
use RoundlyConsulting\PackageToolkit\Contracts\HasRetryAfter;

// Host side — no knowledge of the package's exception classes needed:
try {
    // … call into the package
} catch (HasRetryAfter $e) {
    return response('Too Many Requests', 429)
        ->header('Retry-After', (string) $e->retryAfterSeconds());
}
```

## One handler in the host

To answer every such exception the same way, register one handler in the host’s bootstrap/app.php with $exceptions->map() or $exceptions->respond(). Don’t use $exceptions->render(fn (HasRetryAfter $e) …): Laravel runs map() first, then the exception’s own render() method, and only then the render() callbacks. Many package exceptions implement render() themselves, and when it returns a response a render() callback never fires.

map() turns the exception into Laravel’s own ThrottleRequestsException, which renders as a 429 with the headers you pass. It runs before the exception’s own render(), so it covers every HasRetryAfter:

```php
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Exceptions\ThrottleRequestsException;
use RoundlyConsulting\PackageToolkit\Contracts\HasRetryAfter;

->withExceptions(function (Exceptions $exceptions): void {
    $exceptions->map(fn (HasRetryAfter $e) => new ThrottleRequestsException(
        $e->getMessage(),
        $e,
        ['Retry-After' => (string) $e->retryAfterSeconds()],
    ));
})
```

The mapping also applies when the exception is reported. Laravel does not report a ThrottleRequestsException, so the mapped exceptions stop reaching your logs — usually what you want for “too many attempts”. respond() instead keeps the response the exception rendered and adjusts it; it runs on every rendered response, the exception’s own render() included:

```php
use Illuminate\Http\Request;
use RoundlyConsulting\PackageToolkit\Contracts\HasRetryAfter;
use Symfony\Component\HttpFoundation\Response;

$exceptions->respond(function (Response $response, Throwable $e, Request $request): Response {
    if ($e instanceof HasRetryAfter) {
        $response->setStatusCode(429);
        $response->headers->set('Retry-After', (string) $e->retryAfterSeconds());
    }

    return $response;
});
```

## 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/package-toolkit-for-laravel.md)
