NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Package Toolkit for Laravel

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, not an implementation of a contract, not a string map, or not a model class; also thrown by DatabaseDriver::current() for a driver the enum does not model. The class is final.
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 four item factories (notAStringItem, notAValidListItem, notAnIntegerItem, itemOutOfRange) 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):

FactoryMessage
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.
notAnIntegerList($key, $value)Configuration value [{key}] must be a list of integers (an array or a comma-separated string), [{given}] given.
notAnIntegerItem($key, $item)Configuration value [{key}] must contain only integer items, [{item}] given.
itemOutOfRange($key, $min, $max, $item)Configuration value [{key}] must contain only items between {min} and {max}, [{item}] given.
notAnIntegerOrOff($key, $offWords, $value)Configuration value [{key}] must be an integer or one of [{off words}], [{given}] given.
notAStringMap($key, $value)Configuration value [{key}] must be a map of names to non-empty strings, [{given}] 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 integer(), float() and optionalInteger(); itemOutOfRange() reads the same way for integerList(). notAnIntegerOrOff() lists the off words the call accepted, and optionalInteger() with offWords: [] throws notAnInteger() instead. notAStringMap() names the bad entry name for an entry without one; a bad value under a good name is notAString() keyed {key}.{name}.

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.

The same messages for your own exception

The factories live in the DescribesConfigurationErrors trait (RoundlyConsulting\PackageToolkit\Exceptions\Concerns), each returning static. It carries every factory in the table above except unsupportedDatabaseDriver(). InvalidConfigurationException uses it and stays final — the trait, not inheritance, is how a package shares the vocabulary. Use it in your package’s own configuration exception, so a check the readers don’t cover throws the same message, typed as your exception:

use RoundlyConsulting\PackageToolkit\Exceptions\Concerns\DescribesConfigurationErrors;
use RoundlyConsulting\PackageToolkit\Support\Config;
use RuntimeException;

final class CommentsConfigurationException extends RuntimeException
{
    use DescribesConfigurationErrors;
}

// A check the readers don't cover, in the toolkit's message shape, typed as your exception:
throw CommentsConfigurationException::outOfRange('comments.per_page', 1, 100, $value);
// Configuration value [comments.per_page] must be between 1 and 100, [500] given.

// Pair it with Config::using(), and the readers throw the same class:
Config::using(CommentsConfigurationException::class)->integer('comments.per_page', 20, min: 1, max: 100);
  • The class’s constructor must take the message as its first argument. Every Exception subclass does.
  • The messages are byte-identical to the toolkit’s — a golden test pins every one of them, for InvalidConfigurationException and for a package exception that uses the trait.
  • Every value parameter stays #[SensitiveParameter], and a SensitiveParameterValue is still described by its type only.
  • The trait’s private helpers have long names (describeConfigurationValue(), …), so they cannot collide with a describe() your class already has. A method your class declares wins over the trait’s method of the same name, so declaring your own notAnImplementation() replaces the shared one.

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:

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;
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:

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:

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

By donating, you agree to our donation terms.

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.