Working with Translatable Exceptions

This guide covers the translatable exception hierarchy shipped by derafu/translation.

A Note on Translation Ids

Every example in this guide uses the real English message text as the translation id (e.g. 'The field {field} is required.'), not an abstract key like validation.required. This is deliberate: Symfony’s own translation documentation recommends real text for shared libraries specifically (as opposed to end-user applications, where abstract keys make more sense), so that code stays readable and still produces a sane message even when no translator — or no matching translation — is available. TranslatableExceptionTrait is built around this idea: the message you throw with is always what getMessage() returns when untranslated.

Available Exceptions

The library mirrors PHP’s own SPL exception hierarchy with translatable equivalents.

Core Exceptions

  • Derafu\Translation\Exception\Core\TranslatableException — extends Exception.
  • Derafu\Translation\Exception\Core\TranslatableLogicException — extends LogicException.
  • Derafu\Translation\Exception\Core\TranslatableRuntimeException — extends RuntimeException.
  • Derafu\Translation\Exception\Core\TranslatableJsonException — extends JsonException.

Logic Exceptions

  • Derafu\Translation\Exception\Logic\TranslatableBadFunctionCallException — extends BadFunctionCallException.
  • Derafu\Translation\Exception\Logic\TranslatableBadMethodCallException — extends BadMethodCallException.
  • Derafu\Translation\Exception\Logic\TranslatableDomainException — extends DomainException.
  • Derafu\Translation\Exception\Logic\TranslatableInvalidArgumentException — extends InvalidArgumentException.
  • Derafu\Translation\Exception\Logic\TranslatableLengthException — extends LengthException.
  • Derafu\Translation\Exception\Logic\TranslatableOutOfRangeException — extends OutOfRangeException.

Runtime Exceptions

  • Derafu\Translation\Exception\Runtime\TranslatableOutOfBoundsException — extends OutOfBoundsException.
  • Derafu\Translation\Exception\Runtime\TranslatableOverflowException — extends OverflowException.
  • Derafu\Translation\Exception\Runtime\TranslatableRangeException — extends RangeException.
  • Derafu\Translation\Exception\Runtime\TranslatableUnderflowException — extends UnderflowException.
  • Derafu\Translation\Exception\Runtime\TranslatableUnexpectedValueException — extends UnexpectedValueException.

Errors

PHP’s \Error family has its translatable equivalents too, so a TypeError or a ValueError that a package throws is translatable like any exception:

  • Derafu\Translation\Error\TranslatableError — extends Error.
  • Derafu\Translation\Error\TranslatableTypeError — extends TypeError.
  • Derafu\Translation\Error\TranslatableValueError — extends ValueError.
  • Derafu\Translation\Error\TranslatableArithmeticError — extends ArithmeticError.
  • Derafu\Translation\Error\TranslatableDivisionByZeroError — extends DivisionByZeroError.

Every class above implements TranslatableInterface and uses TranslatableExceptionTrait internally, so they all share the same constructor and trans() method described below.

Not available

  • ErrorException has no translatable version: its constructor takes severity, filename and line, which TranslatableExceptionTrait can not use.
  • Exceptions that a library requires you to throw as they are (for example, the exception that a Symfony interface asks for) can not be translatable. See Lint for how a package allows them explicitly.

Replacing a native exception

The names are the native ones with the prefix Translatable, with the same parent, so catch blocks that catch the native class keep working. In a file that already throws a native exception, an alias changes the class without touching the rest of the file:

use Derafu\Translation\Exception\Logic\TranslatableInvalidArgumentException as InvalidArgumentException;

Using the Trait Directly

TranslatableExceptionTrait

If you can’t (or don’t want to) extend one of the exceptions above — for example, because you already extend something else — use the trait instead:

use Derafu\Translation\Contract\TranslatableInterface;
use Derafu\Translation\Trait\TranslatableExceptionTrait;
use DomainException;
use Throwable;

class OrderException extends DomainException implements TranslatableInterface
{
    use TranslatableExceptionTrait;

    // $defaultDomain is a *typed* property on the trait, so PHP does not
    // allow overriding its default value by simply redeclaring it here
    // (that's a fatal "incompatible property definition" error). Set it in
    // the constructor instead, before calling normalizeMessage().
    public function __construct(
        string|array|TranslatableInterface $message,
        int $code = 0,
        ?Throwable $previous = null,
    ) {
        $this->defaultDomain = 'orders';
        parent::__construct($this->normalizeMessage($message), $code, $previous);
    }

    public static function insufficientStock(string $product): self
    {
        return new self(['Not enough stock for "{product}".', 'product' => $product]);
    }
}

When to Use the Trait vs. Extending a Base Exception

Use the trait when:

  • You already extend another exception class.
  • You need custom behavior beyond translation.

Use one of the base exceptions when:

  • You don’t need custom behavior.

  • You want the simplest possible implementation:

    class ValidationException extends TranslatableDomainException
    {
        // Inherits everything from the trait, with no extra code.
    }
    

The Constructor

All translatable exceptions accept the same three argument shapes:

public function __construct(
    string|array|TranslatableInterface $message,
    int $code = 0,
    ?Throwable $previous = null,
)
  • string: used as both the exception message and the translation id.
  • array: the first element is the message/id, the remaining elements are named ICU parameters (['The field {field} is required.', 'field' => 'email']).
  • TranslatableInterface: a TranslatableMessage (or any other implementation) built directly — useful when you want to reuse the same translatable message in more than one place.

Default Domain and Locale

TranslatableExceptionTrait defines:

protected string $defaultDomain = 'errors';
protected string $defaultLocale = 'en';

Override $defaultDomain in your own exception class to group its messages under a different translation domain. This works fine through normal class inheritance, when you extend one of the built-in exceptions (they already apply the trait for you):

class OrderException extends TranslatableDomainException
{
    protected string $defaultDomain = 'orders';
}

If you use TranslatableExceptionTrait directly in your own class instead (see above), redeclaring $defaultDomain in that same class fails with a fatal “incompatible property definition” error — PHP does not allow a class to override the default value of a trait’s own typed property by redeclaring it in the class that uses the trait. Set it in the constructor instead in that case, as shown in the OrderException example above.

Reading the Message of an Exception

An exception is made of a translatable value: a TranslatableMessage when it is created from a string or an array, or whatever TranslatableInterface it was given. It can be read:

$exception = new NotFoundException(['Item {id} not found.', 'id' => 7]);

$exception->getTranslatable();         // The value, as it is: a TranslatableInterface.
$exception->getTranslatableMessage();  // The same, as a message that can be read.

$message = $exception->getTranslatableMessage();
$message->getMessage();     // 'Item {id} not found.'
$message->getParameters();  // ['id' => 7]
$message->getDomain();      // 'errors'
  • getTranslatable() gives the value without the exception, so it can be carried where an exception can not go: a flash message in a session, a record in a queue. The value has no file, no line and no trace.
  • getTranslatableMessage() gives it as a TranslatableMessageInterface: its id, its parameters and its domain. Use it to log an exception by its id and its parameters, or to send them as data. If the exception was created from a TranslatableInterface that is not a message, it throws a TranslatableLogicException: a custom translatable value is valid, but it can not be read as a message.

Both belong to the interfaces TranslatableAwareInterface and TranslatableMessageAwareInterface, which every exception and error of the package implements. A class of your own that uses TranslatableExceptionTrait has the two methods, and declares the interface if it wants to be recognized by it.

Nested Messages

A parameter can be another translatable message (a TranslatableMessage or a translatable exception). Both are translated: the sentence around and the one inside. It is how an exception adds context to a message, and how text from outside is put inside a sentence of your own, so it can be translated even if the text inside can not:

throw new CertificateException([
    'A problem happened: {message}',
    'message' => new TranslatableMessage('The file is not readable.', [], 'errors'),
]);

A translatable exception can also be the parameter, to say why something failed. Its text is its message, never the dump that PHP makes of an exception (class, file, line and trace): with a translator that has its entry it is translated, and without one its message is used as it was written.

throw new LoadException([
    'Failed to load: {message}',
    'message' => $previous,   // A translatable exception.
]);

An exception that is not translatable as a parameter is its message too.

For a text that you do not own (the message of another library, or of PHP), use it as a plain string parameter inside a sentence in English that is yours, and use ['{message}', 'message' => $text] only if there is no better sentence.

Exporting a Message as Data

TranslatableMessage implements JsonSerializable: json_encode() gives its id, its parameters, its domain and its default locale, with the names of the getters. It is for an API response, a log or a queue, where the message is translated later, by whoever reads it.

$message = new TranslatableMessage('Hello {name}', ['name' => 'Ana'], 'auth', 'es');

json_encode($message);
// {"message":"Hello {name}","parameters":{"name":"Ana"},"domain":"auth","defaultLocale":"es"}

The same data is what getTranslatableMessage() of an exception reads, so json_encode($exception->getTranslatableMessage()) exports the message of an exception.

  • A message inside the message (a nested message) is exported as its own data, with the same four keys.
  • A translatable exception as a parameter is exported as the data of its message. If it was made from a TranslatableInterface that is not a message, it is exported as its text.
  • An exception that is not translatable is exported as its message. json_encode() would make it an empty object.
  • Anything else stays as json_encode() makes it. A date, an object or a resource does not come back as it was: the data is for a view to translate from, not a format to rebuild the message from.

What a store that keeps JSON gives back (the session of Mezzio, for example) has the same names, so a view can translate it from there.

Conventions for a Package

Every exception that a package throws or declares must be translatable, and its messages must be written so that a test can read them (English, array form, literal id and domain). The rules, what not to do, and the test that checks them are in Lint.

Best Practices

  1. Choose the most specific exception type available. It carries semantic meaning for callers doing catch (SomeSplException $e), even before translation comes into play.

  2. Use the real message text as the id, per the note above — don’t introduce abstract keys unless you have a specific reason to (e.g. a dedicated translation-management workflow that needs stable ids independent of wording).

  3. Provide every ICU parameter the message references. A missing parameter is left unexpanded in the output rather than throwing.

  4. Group related exceptions under a domain-specific base class:

    abstract class OrderException extends TranslatableDomainException
    {
        protected string $defaultDomain = 'orders';
    }
    
    class OrderNotFoundException extends OrderException {}
    class OrderValidationException extends OrderException {}
    
On this page

Last updated on 10/10/2026 by Anonymous