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— extendsException.Derafu\Translation\Exception\Core\TranslatableLogicException— extendsLogicException.Derafu\Translation\Exception\Core\TranslatableRuntimeException— extendsRuntimeException.Derafu\Translation\Exception\Core\TranslatableJsonException— extendsJsonException.
Logic Exceptions
Derafu\Translation\Exception\Logic\TranslatableBadFunctionCallException— extendsBadFunctionCallException.Derafu\Translation\Exception\Logic\TranslatableBadMethodCallException— extendsBadMethodCallException.Derafu\Translation\Exception\Logic\TranslatableDomainException— extendsDomainException.Derafu\Translation\Exception\Logic\TranslatableInvalidArgumentException— extendsInvalidArgumentException.Derafu\Translation\Exception\Logic\TranslatableLengthException— extendsLengthException.Derafu\Translation\Exception\Logic\TranslatableOutOfRangeException— extendsOutOfRangeException.
Runtime Exceptions
Derafu\Translation\Exception\Runtime\TranslatableOutOfBoundsException— extendsOutOfBoundsException.Derafu\Translation\Exception\Runtime\TranslatableOverflowException— extendsOverflowException.Derafu\Translation\Exception\Runtime\TranslatableRangeException— extendsRangeException.Derafu\Translation\Exception\Runtime\TranslatableUnderflowException— extendsUnderflowException.Derafu\Translation\Exception\Runtime\TranslatableUnexpectedValueException— extendsUnexpectedValueException.
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— extendsError.Derafu\Translation\Error\TranslatableTypeError— extendsTypeError.Derafu\Translation\Error\TranslatableValueError— extendsValueError.Derafu\Translation\Error\TranslatableArithmeticError— extendsArithmeticError.Derafu\Translation\Error\TranslatableDivisionByZeroError— extendsDivisionByZeroError.
Every class above implements TranslatableInterface and uses
TranslatableExceptionTrait internally, so they all share the same
constructor and trans() method described below.
Not available
ErrorExceptionhas no translatable version: its constructor takesseverity,filenameandline, whichTranslatableExceptionTraitcan 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: aTranslatableMessage(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 aTranslatableMessageInterface: 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 aTranslatableInterfacethat is not a message, it throws aTranslatableLogicException: 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
TranslatableInterfacethat 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
-
Choose the most specific exception type available. It carries semantic meaning for callers doing
catch (SomeSplException $e), even before translation comes into play. -
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).
-
Provide every ICU parameter the message references. A missing parameter is left unexpanded in the output rather than throwing.
-
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 {}