public function hasTranslations(string $translationPath = 'lang'): static
One call registers both styles of Laravel translation file — the keyed PHP arrays and the flat JSON strings — and makes the directory publishable.
$packager ->name('Blog') ->hasTranslations();
The argument is a directory name, not a path
hasTranslations('lang') resolves to ../lang relative to your provider's directory. Pass
'resources/lang' to get ../resources/lang. Unlike the other builders, there is no separate
directory: parameter — the one argument is the directory.
Directory layout
acme/blog/└── lang/ ├── en/ │ ├── messages.php │ └── validation.php ├── cs/ │ └── messages.php ├── pt_BR/ # │ └── messages.php ├── en.json └── cs.json
return [ 'title' => 'Blog', 'post' => [ 'published' => 'Published on :date', 'by' => 'Written by :author', ], 'posts_count' => '{0} No posts|{1} One post|[2,*] :count posts',];
{ "Read more": "Read more", "Leave a comment": "Leave a comment"}
Using them
PHP translations are namespaced under the package short name:
trans('blog::messages.title'); // 'Blog'trans('blog::messages.post.published', ['date' => $date]);trans_choice('blog::messages.posts_count', $posts->count());
<h1>{{ __('blog::messages.title') }}</h1><p>{{ __('blog::messages.post.by', ['author' => $post->author->name]) }}</p>
JSON translations are not namespaced — that is how Laravel's JSON loader works, for packages and applications alike:
__('Read more');
Which means your JSON keys share one global space with the application's. Keep them few, and keep them specific enough not to collide; anything ambiguous belongs in a namespaced PHP file.
Language directory validation
Every subdirectory is checked against the toolkit's Support\Enums\Language enum — the ISO 639-1
set, 180-odd codes. An unrecognised one throws during registration:
Invalid language directory [/…/lang/english].Directory name must be one of the supported languages.
The check is there because a mistyped locale directory does not fail loudly on its own — Laravel simply never finds the translations, and the fallback locale is served instead. Failing at registration turns a silent content bug into an immediate one.
Region locales are supported. Only the language part before a - or _ is validated, so
pt_BR, en-US and zh_CN all pass.
use NyonCode\LaravelPackageToolkit\Support\Enums\Language; Language::codes(); // Collection<string> ['ab', 'aa', 'af', …]Language::names(); // Collection<string> ['Abkhazian', 'Afar', …]Language::collection(); // Collection<Language> Language::CS->value; // 'Czech'Language::CS->name; // 'CS'
Missing and empty directories
| Situation | Result |
|---|---|
| Directory does not exist | DirectoryNotFoundException |
| Directory exists but is empty | returns silently, package is not translatable |
| Subdirectory is not a language code | InvalidLanguageDirectoryException |
The empty-directory case is deliberate: a package can commit an empty lang/ placeholder without
declaring itself translatable, which keeps isTranslatable() honest.
Publishing
php artisan vendor:publish --tag=blog::translations
The whole directory is copied to lang/vendor/{short-name}/:
lang/vendor/blog/├── en/messages.php├── cs/messages.php├── en.json└── cs.json
On an application still using the pre-Laravel-9 layout — one where the lang_path() helper does not
exist — the destination falls back to resources/lang/vendor/blog/. The toolkit checks for the
helper rather than the framework version.
Laravel checks the vendor override directory before the package's own, so a consumer who
publishes and edits lang/vendor/blog/en/messages.php gets their version. Keys they did not
override still fall through to yours.
Adding a language as a consumer
Only the file needs to exist — nothing to register:
lang/vendor/blog/de/messages.php
app()->setLocale('de'); trans('blog::messages.title');
Translating your own package's output
Everything your package renders should go through the translator, including exception messages and command output:
public function handle(): int{ $count = Post::onlyTrashed()->forceDelete(); $this->info(trans('blog::messages.pruned', ['count' => $count])); return self::SUCCESS;}
Introspection
$packager->isTranslatable(); // bool$packager->translationPath(); // absolute path to the lang directory$packager->loadJsonTranslate(); // bool — true if any .json file was found
loadJsonTranslate() is set by scanning the directory recursively for a .json extension, so a
JSON file nested inside a locale directory is detected too.
Testing
test('the package registers its translations', function () { expect(trans('blog::messages.title'))->toBe('Blog');}); test('the package registers its json translations', function () { expect(trans('Read more'))->toBe('Read more');}); test('translations are publishable', function () { $this->artisan('vendor:publish --tag=blog::translations')->assertExitCode(0); expect(lang_path('vendor/blog/en/messages.php'))->toBeFile();});