Failure Handling
Providers throw by design. A missing source, corrupt bytes, or a manipulator the backend cannot express surfaces as an exception — the right behaviour in development, where you want the failure loud, and for programmatic callers who need to know a variant could not be produced. The direct provider call is the strict API and always throws:
$url = $provider->url($image); // throws on any failure — never degrades
In production a single broken image should not take the page down. One unreadable upload, one source the storage briefly can’t reach, must not turn a whole gallery into a stack trace. That is what a failure handler is for.
The seam
Inject a FailureHandlerInterface at the factory. When set, it is consulted whenever materializing an image fails at the render boundary:
use Timber\Chainsaw\FallbackImage;
use Timber\Chainsaw\ImageFactory;
$factory = new ImageFactory(
provider: $provider,
failureHandler: new FallbackImage('placeholder.png'),
);
Null — the default — keeps the throwing behaviour untouched. The seam only wraps Image::__toString() and Image::toDataUri(), so it covers every rendered URL: the src, each srcset candidate, and every <picture> source materialize through __toString() and each pass through the handler on its own. The direct $provider->url($image) call bypasses the seam and still throws.
Dev and prod wiring
Do not inject a handler in development — you want a broken source to blow up so you notice it. Inject one in production. Gate it on the environment:
use Timber\Chainsaw\FallbackImage;
use Timber\Chainsaw\ImageFactory;
$production = $_SERVER['APP_ENV'] === 'prod';
$factory = new ImageFactory(
provider: $provider,
failureHandler: $production
? new FallbackImage('placeholder.png', logger: $logger)
: null,
);
The handler is per-factory. In a FactoryRegistry, each named factory carries its own — a public-facing backend can degrade while an internal one keeps throwing:
use Timber\Chainsaw\FactoryRegistry;
$registry = new FactoryRegistry(
default: new ImageFactory(provider: $cdn, failureHandler: new FallbackImage('placeholder.png')),
factories: [
'internal' => new ImageFactory(provider: $local), // no handler — throws
],
);
FallbackImage
FallbackImage is the reference handler. It degrades in two rungs.
Rung one re-renders a configured fallback source through the same manipulator chain that failed. The substitute comes out at the requested dimensions and format, so width/height attributes, the layout, and each srcset candidate stay coherent — every candidate degrades at its own width, not at one shared size.
Rung two is the floor, reached only when rung one also fails (the storage is down, or the chain contains a manipulator the backend refuses — both reproduce on any source). It serves the configured default: src if you set one, otherwise a generated gray SVG data URI sized to the requested box. The floor does no read, decode, or cache write, so it cannot fail.
use Timber\Chainsaw\FallbackImage;
$handler = new FallbackImage(
source: 'placeholder.png', // rung one: re-manipulated through the failed chain
default: '/static/oops.svg', // rung two: static last resort (omit for a generated SVG)
logger: $logger, // PSR-3 — see below
);
Pass a PSR-3 logger:. Every degradation logs a warning with the triggering exception in context. The default is a NullLogger, so nothing is recorded unless you wire one — and a silent fallback masks the real problem it is papering over.
Writing your own handler
Implement the interface. handle() receives the exception and the fully readable Image — source, manipulators, encoding, and dimensions() are all available. Return any src verbatim, or rethrow to propagate the failure:
use Timber\Chainsaw\FailureHandlerInterface;
use Timber\Chainsaw\Image;
final readonly class SvgPlaceholder implements FailureHandlerInterface
{
public function handle(\Throwable $exception, Image $image): string
{
$dimensions = $image->dimensions();
// dimensions() axes are nullable — no metadata means no known box.
// preserveAspectRatio gives cover/contain semantics with no CSS.
$svg = sprintf(
'<svg xmlns="http://www.w3.org/2000/svg" width="%1$d" height="%2$d" '
. 'viewBox="0 0 %1$d %2$d" preserveAspectRatio="xMidYMid slice">'
. '<rect width="100%%" height="100%%" fill="#eee"/></svg>',
$dimensions->width ?? 1,
$dimensions->height ?? 1,
);
return 'data:image/svg+xml,' . rawurlencode($svg);
}
}
Reading $image->dimensions() lets a handler size its output to the box the failed image asked for.
The Image you receive has no failure handler attached. Materializing it — or a withSource() derivative — throws on failure instead of re-entering your handler, so a naive return (string) $image->withSource('placeholder.png'); degrades once and then propagates rather than looping forever.
Caveats
- A chain with no box (a bare
width()) renders the fallback at the fallback’s own aspect ratio — the chain never pinned a height, so nothing constrains it to the original’s shape. Use a box op (cover(),contain(),pad()) when the substitute must match the layout slot. - On a double failure the result is bounded, not the time. There is no circuit breaker — every failed image still attempts rung one, so if the infrastructure is down each render pays that cost before hitting the floor. Short-circuiting a known-down backend is an application-infrastructure concern, not this seam’s.
toDataUri()on a provider that cannot inline at all (a URL-grammar CDN, noInlineProviderInterface) throwsUnsupportedOperationregardless of the handler. That is a wiring error, not a per-image failure, so it fails fast rather than degrading — the placeholder layer depends on it (athumbhash/blurhashplaceholder on a non-inline provider degrades to no background, see the placeholders page). The seam still catches per-image data failures raised insidedataUri()itself.- In a
toDataUri()failure, rung one returns an http URL (from$provider->url()), which is a validsrcin any sink but is not a data URI. Only the SVG floor is a true data URI.