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.
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:
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 cryptoBy 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.