---
title: "Lint"
description: "Lint"
type: "docs"
category: "doc"
tags: []
authors: [Anonymous]
date: "2026-10-08"
last_update: "2026-10-08"
time_minutes: 15
draft: false
unlisted: false
url: "https://www.derafu.dev/docs/core/translation/lint"
---

# Lint

`Derafu\Translation\Lint` has tools that read the code of a package to check that everything it shows to people can be translated. They are for tools and tests, not for the code that runs (see [Code Quality](https://www.derafu.dev/docs/core/library-skeleton/code-quality#lint-code)). They need `nikic/php-parser`, which is optional: install it as a development dependency.

They read the code, they never run it. That is what makes them useful and what limits them: they can only check what can be known by reading. This page says how to write the code so that it can be checked, what breaks the check, and what the tools can not see.

> **If the conventions of this page are not followed, the package can not be validated.** The tools do not guess: a message or an exception that can not be read is reported, and the test of the package fails until the code is written in a way that can be read (or the case is fixed explicitly in the test, see [Messages that can not be read by nature](#messages-that-can-not-be-read-by-nature)). Nothing is skipped silently.

## The audit

`TranslationAudit` does everything in one call: it finds the messages that the code builds, checks them against the catalogues of the package, and finds the exceptions that are not translatable. The test of a package is a few lines:

```php
use Derafu\Translation\Lint\TranslationAudit;

final class TranslationMessagesTest extends TestCase
{
    public function testThePackageIsTranslated(): void
    {
        $report = (new TranslationAudit())->audit(
            dirname(__DIR__, 2) . '/src',
            new MyPackageTranslationResourceProvider(),
        );

        $this->assertFalse($report->nothingFound);
        $this->assertSame([], $report->describe($report->dynamicMessages));
        $this->assertSame([], $report->describe($report->missingTranslations));
        $this->assertSame([], $report->describe($report->notUsedBySources));
        $this->assertSame([], $report->describe($report->notTranslatable));
    }
}
```

`audit()` takes:

| Argument | What it is |
| --- | --- |
| `$directory` | The code to read (every `.php` file, recursively). |
| `$providers` | The catalogues **of that code**: one `TranslationResourceProviderInterface` or an iterable of them. |
| `$locale` | The locale whose catalogue is checked. `es` by default. |
| `$allowedThrowables` | Classes of exceptions that are not translatable and are allowed, by their full name. |
| `$messageMethods` | The methods of the package that receive the id of a message (`MessageMethod`). |

Use the catalogues of **that** code. A message of a package is translated by the catalogue of that package, and which one belongs to which is something only you know. That is why the tools do not decide.

### The report

`TranslationAuditReport` only has facts. It never says that something is a problem: that is for your test to say, by asserting that the lists it cares about are empty.

| Property | The fact |
| --- | --- |
| `dynamicMessages` | Messages that are not a literal, so they can not be checked. |
| `missingTranslations` | Messages that have no entry in the catalogues, in their domain. |
| `notUsedBySources` | Entries of the catalogues that no message of the code that was read uses. |
| `notTranslatable` | Exceptions that are thrown or declared and are not translatable, and were not allowed. |
| `nothingFound` | `true` when no message was found at all (the directory is wrong, or the code has none). |

`describe()` turns a list of findings into lines, one each, for the message of a failed test: relative path and line, and what it is (`"Message {name}." [errors]`, `Class extends Parent`, or the function and call for a message that can not be read). When a test fails, the failure says exactly where.

`notUsedBySources` is relative to what was read: an entry can be used by something that was not, for example a template. Decide for each package whether to assert it empty.

## Conventions

These are the rules that make a package checkable.

### Exceptions

1. **Every exception that the package throws, or declares, is translatable.** Extend one of the `Translatable*` classes of this package, or use `TranslatableExceptionTrait` and implement `TranslatableInterface` ([Exceptions](exceptions)). That includes the `\Error` family: a `TypeError` or a `ValueError` has its translatable class.
2. **Write the message as the English text, in the form of an array**, with named parameters:

    ```php
    throw new NotFoundException(['Resource "{name}" not found.', 'name' => $name]);
    ```

    The text is the id of the translation and the fallback. The Spanish (or any other language) is only in the catalogue, never in the code.
3. **An external text goes inside a phrase of your own**, in English, as a parameter:

    ```php
    throw new MyException(['A problem happened: {message}', 'message' => $e->getMessage()]);
    ```

    The text of another library or of PHP (`json_last_error_msg()`, an exception of a vendor) can not be translated by you, but the sentence around it can. If there is no better sentence, use the generic one with the key `message`: `['{message}', 'message' => $text]`.
4. **To add context to a message that is its own, nest it**: a parameter can be another `TranslatableMessage` (or a translatable exception), and both are translated, the sentence and the one inside.
5. **An exception that must be thrown as it is** (because a contract requires that class, for example a Symfony interface that asks for `CommandNotFoundException`) is allowed explicitly with `$allowedThrowables`. It is a decision of the package, written in its test, and not a default.

### Messages

6. **Write the id as a literal string.** The tool reads the text, so the text has to be there.
7. **Write the domain as a literal**, or do not write it (the exception has its `$defaultDomain`).
8. **Use ICU named parameters in the message** and give the values in the array. Do not build the text with `sprintf()`, concatenation or an interpolated string.
9. **A catalogue entry has the same id as the message**, in the domain of the exception. The catalogue is in `resources/translations/<domain>+intl-icu.<locale>.php` and has a `TranslationResourceProvider` that the package registers (tagged `derafu_translation.resource_provider`).

### Helper methods

A class that has its own method to translate (for example `private function trans(string $message, array $parameters = [])`) is read in a different way: inside the method the id is a variable, so the tool reads the **calls** to the method, where the literals are. For that, declare it with `MessageMethod` ([below](#helper-methods-messagemethod)).

10. **Do not name a helper like the method of another object** that you also call in the same code. The tool finds calls by the name of the method. A helper called `trans` and a call `$translator->trans(...)` on another object look the same when the receiver can not be known by reading, and the second is reported as a message that can not be read. Give the helper a name that is yours (`translate`, `message`, `text`) or call the standard form.
11. **If the id of a helper comes from a constant, a map or a variable**, the tool can not read it. Write each call with a literal (`$this->trans('Copy value')`), or use `match` with a literal in each branch.

## What not to do

Each of these makes the code impossible to check by reading, and the package can not be validated while it is there:

| Do not | Why | Do |
| --- | --- | --- |
| `throw new MyException(sprintf('Failed %s.', $x))` | The id changes with every value: it can not have an entry. | `['Failed {x}.', 'x' => $x]` |
| `throw new MyException("Failed {$x}.")` | Same. | `['Failed {x}.', 'x' => $x]` |
| `throw new MyException('Failed: ' . $e->getMessage())` | Concatenation, same. | `['Failed: {message}', 'message' => $e->getMessage()]` |
| `throw new MyException($message)` where `$message` comes from outside | The tool can not know it. | Pass the literal where the exception is made. |
| `new TranslatableMessage($text, [], $domain)` with `$domain` a variable | The domain can not be read, so the entry can not be found. | Write the domain as a literal. |
| `throw new \RuntimeException(...)`, `\InvalidArgumentException`, `\LogicException`, `\JsonException`, `\TypeError`, `\ValueError`, `\BadMethodCallException`, ... | Not translatable. | The `Translatable*` class (same name, same parent). |
| An exception of your own that `extends \Exception` (or any native one) | Its declaration is reported, and so is every `new` of it. | `extends TranslatableException` (or the one that fits). |
| A helper whose id is a variable and is not declared | Its calls are not messages for the tool, so the entries look unused. | Declare it with `MessageMethod`. |
| An exception thrown only to be caught in the same method | The tool sees the `throw new` and can not see the `catch`. | Do not throw it (return, or use a condition), or allow the class. |
| Spanish (or another language) in the message in code | The id is English: it is what shows when there is no translation. | English in code, Spanish in the catalogue. |

### The same exception, native and translatable

The translatable exceptions are named like the native ones with the prefix `Translatable`. In a file that used `InvalidArgumentException`, the cheapest change is an alias, with no other line touched:

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

The tool tells them apart by the class that the name resolves to, not by the text, so the alias is read as the translatable one.

## Messages that can not be read by nature

Some code can not have a literal. The clearest case is `TranslatableExceptionTrait`, which wraps in a `TranslatableMessage` the message that whoever throws the exception gives it. Those are reported as dynamic, correctly.

You fix them in the test, by what they are, not by where they are. The identity of a dynamic message is the function and the whole call:

```php
$this->assertSame(
    [
        'Derafu\\Translation\\Trait\\TranslatableExceptionTrait::normalizeMessage: '
            . 'new \\Derafu\\Translation\\TranslatableMessage($message, [], $this->defaultDomain, $this->defaultLocale)',
    ],
    array_map(fn (MessageReference $reference) => $reference->identity(), $report->dynamicMessages)
);
```

It does not change when other lines of the file do. It does change when that call changes, and it fails if it disappears or if a new one appears anywhere else, so a new message that can not be read never goes unnoticed. If one is fixed in the code, take it out of the list.

Do not fix a dynamic message by pinning the file or the line: a new one in the same file would be hidden.

## Helper methods: `MessageMethod`

```php
use Derafu\Translation\Lint\MessageMethod;

$report = (new TranslationAudit())->audit(
    $src,
    new MyPackageTranslationResourceProvider(),
    messageMethods: [
        new MessageMethod(ContactController::class, 'trans', domain: 'contact-form'),
    ],
);
```

| Argument | What it is |
| --- | --- |
| `$class` | The class that has the method (use `::class`). Calls on its subclasses count. Whether the class and the method exist is checked, and a mistake raises an `InvalidArgumentException` and does not hide the check. |
| `$method` | The name of the method. |
| `$domain` | The domain of its messages. If it is not given, it is the default of `TranslatableMessage` (`messages`). |
| `$id` | Where the id is: the position (starting at 0) or the name of the argument. 0 by default. |
| `$domainArgument` | Where the domain is, if the call can give its own. |

The tool then finds each `$this->trans('...')` (and `self::`, `static::`, `parent::` and `Class::` calls) as a message with its id and domain, and checks it like the others. The method's own body, where the id is a variable, is not reported.

## The pieces

`TranslationAudit` is made of two scanners that can be used on their own.

### `MessageReferenceScanner`

Finds every message that the code builds to be translated: the one given to a translatable exception and to `new TranslatableMessage(...)`, and the calls of the methods declared with `MessageMethod`.

```php
use Derafu\Translation\Lint\MessageReferenceScanner;

$scanner = new MessageReferenceScanner(/* list<MessageMethod> */);

foreach ($scanner->scanDirectory($src) as $reference) {
    $reference->id;         // 'Resource "{name}" not found.', or null when it is not a literal.
    $reference->domain;     // 'errors', or null when it is not a literal.
    $reference->class;      // The exception (or TranslatableMessage) that is built.
    $reference->file;
    $reference->line;
    $reference->function;   // 'Class::method', or 'Class::method::{closure}'.
    $reference->expression; // The whole call, when the message can not be read.
    $reference->isDynamic();
    $reference->identity(); // function + whole call, or the id.
}
```

`scanFile()` does the same for one file. `scanDirectory()` takes every `.php` file of a directory and its subdirectories, in order of file and then of line.

What it finds:

- **The message of a translatable exception**: a string or the first element of an array, also as a named argument (`message:`).
- **The domain**: the default of its `$defaultDomain` property (`errors` unless the class sets another one). For `new TranslatableMessage(...)` it is the third argument, or `messages`.
- **Both branches of a ternary** (`$a ? 'One.' : 'Two.'`) as two messages. In `$a ?: 'Two.'` the left side is dynamic and the right is a literal.
- **What a class builds for itself**: `parent::__construct([...])`, `new static([...])` and `new self(...)` in its factories, and the default of its `$message` parameter.
- **Anonymous classes** that extend a translatable one.

A message that is a parameter of a constructor and is only passed on to `parent::__construct($message)` is not a reference: it is the message of whoever calls the constructor, and that call is found where it is made. An empty id is ignored.

### `ThrowReferenceScanner`

Finds the exceptions that are not translatable: every `new` of a throwable that does not implement `TranslatableInterface` (whether it is thrown there, later, or given back by a factory), and every class that is declared extending one.

```php
$references = (new ThrowReferenceScanner())->scanDirectory($src);

$references[0]->class;         // 'RuntimeException'
$references[0]->extends;       // null for a throw, or the parent for a declaration.
$references[0]->isDeclaration();
$references[0]->loadable;      // false if the class could not be loaded.
```

A `throw new` of a class that can not be loaded, or of a class that is a variable, is reported as unknown: what is thrown is an exception, and finding nothing would look like a clean result.

### How they work

- They read the code, so comments and strings that only look like a `new` are ignored, and the references are found wherever a `new` can be (closures, arrow functions, arguments, anonymous classes).
- They tell the classes apart by what they are (a throwable that implements `TranslatableInterface`), not by their name. Classes must be loadable (autoloaded), because they are checked by reflection.
- A file that can not be parsed raises the parser's `PhpParser\Error`. A directory or file that does not exist raises an `InvalidArgumentException`. They fail loudly: a clean result must never be the result of not having read.

## Known limits

These are things the tools can not see. They are not bugs: they are what reading code, without running it, can know. Knowing them is part of using the tools.

1. **`throw $e` of an exception that was not made in the code that is read.** An exception that is caught and thrown again, or one that a function returns, is not seen: without the types of the code it is not known which class it is. Only a `new` is seen.
2. **A thrown and caught exception looks like a thrown one.** The tool sees the `throw new` and not the `catch`, so an exception that is thrown to be caught in the same method (for control flow) is reported. Rewrite the code without it, or allow the class with `$allowedThrowables`.
3. **Helper methods are found by their name and their receiver.** The receiver is known when it is `$this`, `self`, `static`, `parent` or the name of a class. On any other receiver (a variable, a property, the result of a call) it is not known by reading, and if the method has the name of a declared helper it is reported as a message that can not be read. That is what makes a public helper noisy when other objects have a method with the same name. Rename the helper, or call it in a way that the receiver is known.
4. **Calls of a helper on another instance are not seen**, for the same reason: `$other->trans('Text')` is not read as a message of the helper of the class of `$other`.
5. **A helper whose id is not a literal in the call** (a constant, a map, a variable) is reported as a message that can not be read. The id has to be where the call is.
6. **`notUsedBySources` is relative to what was read.** Messages that are used only by something that is not PHP code in the audited directory, for example a template, look unused. If the package has such messages, do not assert this list empty.
7. **It checks that the entry exists, not that it is good.** A catalogue entry with the wrong translation, a missing parameter or a different meaning passes the audit. It is for a person to review.
8. **Catalogues are shared by domain.** Two packages that use the domain `errors` and the same id with different translations override each other, and no test of a single package can detect it. The ids are English phrases, so it is unlikely, but it can happen.
9. **`TranslatableMessage::trans()` only knows the entries of translators that expose their catalogues** (`TranslatorBagInterface`, like Symfony's). With another translator, a message without an entry is whatever that translator returns.
10. **Native classes with their own constructor are not covered.** `ErrorException` takes `severity`, `filename` and `line`, which `TranslatableExceptionTrait` can not use, so there is no translatable version of it.
11. **Anything that is not PHP code is out of reach**: texts in templates, in configuration, in JavaScript or in the database.
12. **Reading, not types.** The tools do not infer types. A class that is not loadable (a missing dependency) is not known to be translatable, and it is reported.



---
Last updated on 08/10/2026

