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.

Lists of Messages

When what has to be shown is several messages and it is not known how many (the errors of a form, the reasons of a failure, the allowed values), use a TranslatableList. Each item is translated, and then they are joined, in the language of the translator. It is a translatable value, so it can be the parameter of a message:

use Derafu\Translation\TranslatableList;
use Derafu\Translation\TranslatableMessage;

throw new FormException([
    'The form is not valid. {errors}',
    'errors' => TranslatableList::separated([
        new TranslatableMessage('The field {field} is required.', ['field' => 'email'], 'errors'),
        new TranslatableMessage('The password is too short, or it is too common.', [], 'errors'),
    ]),
]);
// en: The form is not valid. The field email is required. The password is too short, or it is too common.
// es: El formulario no es válido. El campo email es obligatorio. La contraseña es muy corta, o es muy común.

There are two ways to join the items, because they are two different cases:

enumeration() separated()
For Short items: names, values, labels. Items that are sentences, like errors.
Result red, green and blue The first one. The second one.
Joined by The separator and the last conjunction of the language (a, b y c, a, b, and c), with IntlListFormatter. The separator that is given, a space by default. The text of each item is not changed.
Options TYPE_AND (“a, b and c”), TYPE_OR (“a, b or c”) or TYPE_UNITS (“5 ft, 6 in”), and the width. The separator (' ', '; ', "\n").

Items that are sentences must not be an enumeration: they have their own commas, conjunctions and full stops, so the result would be ambiguous (It could not be read., the password is wrong. and the file is missing). separated() leaves each sentence as it is.

  • An item is a TranslatableInterface or a string. A string is used as it is, without translating it.
  • A list inside another list is translated too.
  • An empty list is an empty text, and a list with one item is that item.
  • The language of an enumeration is the one given to trans(); if it is not given, the one of the translator, and if the translator does not have one, English. Without a translator (__toString()), the items are as their own __toString() says and an enumeration is in English.
  • enumeration() throws a TranslatableInvalidArgumentException if the type or the width is not one of IntlListFormatter.

A list is JsonSerializable like a message: json_encode() gives its items, its separator, its type and its width, and an item that is a message is its own data (see below).

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 30/10/2026 by Anonymous