Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Extractor Laravel Package

php-translation/extractor

PHP Translation Extractor scans PHP and Blade source files for translatable strings, pulling calls such as __(), trans(), and @lang into structured translation files. It helps keep Laravel and Symfony language files in sync with your code and reduces missing or stale translations.

View on GitHub
Deep Wiki
Context7

Getting Started

Install as a dev dependency. It's a build-time scanner, not a runtime service — nothing in the library is needed while serving requests.

composer require --dev php-translation/extractor
# requires PHP 8.1+ and nikic/php-parser ^5

Where to look first. Don't read the README top-to-bottom; read three directories in this order:

  1. src/Extractor/ — PhpExtractor, BladeExtractor, TwigExtractor. These are the entry points.
  2. src/Visitor/ — the actual matching rules (Symfony translator calls, gettext functions, form types, constraints). This is where you find out what your codebase will and won't match.
  3. src/Model/Source and src/Model/SourceCollection — the output contract. Everything you build sits on top of this.

The 10-minute spike. Before writing any Artisan command, point the PHP extractor at one controller and dump what comes back:

use PhpParser\ParserFactory;
use PhpTranslation\Extractor\Extractor\PhpExtractor;

// createForHostVersion() is what 2.3.2 introduced — don't use the old
// create(ParserFactory::PREFER_PHP7) form, it caps you at an old grammar.
$parser    = (new ParserFactory())->createForHostVersion();
$extractor = new PhpExtractor($parser);

$sources = $extractor->extractFile(app_path('Http/Controllers/DashboardController.php'));

foreach ($sources as $source) {
    dump($source->getCode(), $source->getLocations());
}

If that prints an empty collection for a file you know has __('Dashboard') in it, stop and fix extractor coverage before building anything else — that's the make-or-break step for a Laravel codebase (see Gotchas).

First real use case: an audit, not a generator. Point it at app/ + resources/views/, collect every key, then diff against lang/en.json. That gives you a one-page report of "strings used in code but missing from the translation file" — immediately useful, zero risk, and it validates extractor coverage on your actual code before you trust it to write files.

Implementation Patterns

Wrap it in an Artisan command, not a shell script. You want it discoverable, testable, and runnable in CI. One command that owns Finder config, extractor selection, and error tolerance:

use Illuminate\Console\Command;
use PhpParser\ParserFactory;
use PhpTranslation\Extractor\Extractor\{PhpExtractor, BladeExtractor};
use PhpTranslation\Extractor\Model\SourceCollection;
use Symfony\Component\Finder\Finder;

public function handle(): int
{
    $parser = (new ParserFactory())->createForHostVersion();
    $php    = new PhpExtractor($parser);
    $blade  = new BladeExtractor($parser); // ctor signature varies by release; check the source

    $all = new SourceCollection();

    $files = (new Finder())->files()
        ->in([base_path('app'), base_path('resources/views'), base_path('routes')])
        ->exclude(['vendor', 'node_modules', 'storage'])
        ->name('*.php');

    foreach ($files as $file) {
        $isBlade   = str_ends_with($file->getFilename(), '.blade.php');
        $extractor = $isBlade ? $blade : $php;

        try {
            foreach ($extractor->extractFile($file->getRealPath()) as $source) {
                $all->add($source); // merges duplicate keys, keeps every location
            }
        } catch (\Throwable $e) {
            // one unparseable file must never kill the run
            $this->warn("skipped {$file->getRelativePathname()}: {$e->getMessage()}");
        }
    }

    // ...report / write
}

Own the writer yourself. The extractor hands you keys and locations; deciding what lands in lang/en.json is your code. The safe merge is add-only — never rewrite a value a translator already filled in:

$existing = json_decode(@file_get_contents(lang_path('en.json')), true) ?: [];

$found = [];
foreach ($sources as $source) {
    $found[(string) $source->getCode()] = ''; // empty value = "needs translation"
}

$added = array_diff_key($found, $existing);
file_put_contents(
    lang_path('en.json'),
    json_encode($existing + $added, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)
);

$this->info(count($added) . ' new key(s); ' . count($existing) . ' untouched.');

Add a --dry-run flag from day one. The default invocation in a PR should print a diff, not mutate files.

Map path → translation domain in the writer. The extractor returns the raw key string with no idea that app/Http/Controllers/Admin/ should write into lang/en/admin.php while resources/views/emails/ goes into emails.php. Build a small path => domain map in your command — it's five lines and saves you a painful re-keying exercise later.

Custom visitor for Laravel helpers. The PHP visitor set is Symfony/gettext-oriented. If __() / Lang::get() / trans() with a domain argument isn't covered in your version, a visitor is ~30 lines:

use PhpParser\{Node, NodeVisitorAbstract};
use PhpTranslation\Extractor\Model\SourceCollection;

final class LaravelHelperVisitor extends NodeVisitorAbstract
{
    public function __construct(private SourceCollection $sources) {}

    public function enterNode(Node $node): ?Node
    {
        if (!$node instanceof Node\Expr\FuncCall || !$node->name instanceof Node\Name) {
            return null;
        }
        if (!in_array($node->name->toLowerString(), ['__', 'trans'], true)) {
            return null;
        }

        // 2.4.2 fixed an args-array index crash — replicate the guard
        $arg = $node->getArgs()[0] ?? null;
        if ($arg?->value instanceof Node\Scalar\String_) {
            // build a Source(code: string, line: $node->getStartLine()) and add it
        }

        return null;
    }
}

// Registration API differs across releases (addVisitor() vs ctor array) —
// confirm against src/Extractor/PhpExtractor.php in the installed version.
$php->addVisitor(new LaravelHelperVisitor($all));

Snapshot the key list in the repo. Commit tests/fixtures/translation-keys.json and regenerate it in CI. New strings then show up as reviewable PR diffs, and the idempotency check (run twice, empty diff) catches Finder ordering surprises.

- name: Translation key snapshot
  run: |
    php artisan translations:extract --json > tests/fixtures/translation-keys.json
    git diff --exit-code tests/fixtures/translation-keys.json

Use it as a TMS feed. SourceCollection → JSON export is a clean input for Crowdin/Lokalise upload steps. This is the pattern where the library earns its keep most: it's the scan layer, your pipeline is the rest.

Gotchas and Tips

It extracts; it does not write translation files. Despite descriptions that mention creating/updating lang/*.php and .json, this package's job ends at SourceCollection. The translation:extract-style file writing lives in the Symfony bundle/CLI layer (php-translation/symfony-bundle, php-translation/cli). In Laravel you supply that layer yourself — plan for it, it's half the work.

Laravel coverage is not first-class. The library is Symfony-shaped: Symfony TranslatorInterface::trans(), gettext functions, FormTypeInterface, validator constraints. Blade (@lang, {{ __('…') }}) is supported, but treat the PHP side as unproven until your spike says otherwise. Verify __(), trans() with a domain arg, Lang::get(), @choice, and trans_choice() individually on a scratch file.

nikic/php-parser ^5 only (since 2.2.2). If any other dev tool pins parser 4 — older Rector, PHPStan bridges, php-cs-fixer setups — Composer will refuse. Diagnose with composer why-not nikic/php-parser ^5 before you burn an hour on a solver conflict.

Parsing follows the host PHP version (2.3.2). That's the fix that made modern syntax work, but it means your extraction job must run on the highest PHP in your matrix. Run extraction on 8.1 against an 8.3 codebase using property hooks or asymmetric visibility and you get parse failures, not warnings. Pin the CI job explicitly.

Blade files are not PHP files. *.blade.php will explode in PhpExtractor and may produce garbage in the Blade extractor if it's mostly HTML. Route by filename, and keep the per-file try/catch — a single weird view shouldn't abort a 2,000-file run.

Dynamic keys are invisible. __($key), __("errors.{$code}"), sprintf-composed keys, keys built in a loop — none of it is extractable. Literal concatenation got support in 2.1.0, but anything runtime-resolved needs a manual whitelist file you merge in. Budget for it; every Laravel app has a handful.

Annotations are gone. Doctrine annotations were dropped in 2.3.0 and the annotation classes deprecated in 2.3.1. Anything relying on @Message-style constraint scanning must move to PHP attributes. If you inherited code that subclasses an annotation visitor, it's dead on 2.3+.

Twig visitor internals moved. 2.2.1 switched FilterExpression to attribute access. Custom Twig visitors calling getNode('filter') break — use getAttribute('filter').

Exclude aggressively. Finder over vendor/ or node_modules/ turns a 5-second command into a coffee break. Also exclude storage/framework/views — those are compiled Blade files and will double-count everything.

Location data is approximate. Line numbers come from AST node start positions, so a multi-line __() call reports the call's start line, not the string's. Fine for "used in 12 places" reporting; don't build a precise click-to-line IDE integration on it.

Duplicate keys collapse by design. SourceCollection merges same-key sources and keeps multiple SourceLocations — that's correct. If you need per-usage rows, iterate getLocations() instead of the collection.

Keep it out of production. --dev plus composer install --no-dev in the deploy image. There is no runtime value here, only extra parser code in your autoloader.

Decide key conventions before the first run. Extraction gives you whatever literal is in the code. Retrofitting domain.file.key after 400 strings are extracted is a manual rewrite of both code and lang files — do it once, up front, even if it's just a lint rule that rejects __('Save').

Extension point of choice is a custom ExtractorInterface implementation. If you have Vue/JSX/TS files with translation calls, write a dedicated extractor and register it alongside the PHP/Blade ones. Do not regex-hack the PHP extractor to accept non-PHP input — that path leads to silent false negatives.

Symfony 8 is allowed as of 2.4.0. Harmless for Laravel, but it means Composer may resolve newer symfony/* versions than your app expects. If symfony/console or symfony/finder is pinned in your app, check the constraint interaction after the first composer update.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
mehedimi/laravel-amp-mail
happones/laravel-vue-i18n-generator
jwerd/inertia-table
siberfx/flysystem-wasabi
hollyit/token-replace
ippanelcom/laravel-sdk
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel