pictastudio / translatable
Polymorphic translations for Laravel models
Fund package maintenance!
Requires
- php: ^8.4
- illuminate/contracts: ^12.0
- illuminate/database: ^12.0
- illuminate/http: ^12.0
- illuminate/queue: ^12.0
- illuminate/support: ^12.0
- laravel/ai: ^0.2.6
Requires (Dev)
- laravel/pint: ^1.0
- orchestra/testbench: ^10.4
- pestphp/pest: ^4.0
- pestphp/pest-plugin-laravel: ^4.0
README
Single-table polymorphic translations for Laravel Eloquent models, with optional AI-powered translation workflows.
Features
- One shared
translationstable for every translatable model. - Locale-aware attribute access like
$post->titleand$post->{'title:fr'}. - Mass assignment via
attribute:locale, locale-keyed arrays, or an optional wrapper key. - Fallbacks across requested locale, fallback locale, and base model columns.
- Helpers for reading, cloning, serializing, and deleting translations.
- Optional request middleware that sets the app locale from a header.
- AI translation through the Laravel AI SDK, with batching, queues, events, and API endpoints.
- Auto-discovery of translatable models for commands and API consumers.
Installation
composer require pictastudio/translatable
Laravel auto-discovers the service provider.
The quickest setup path is:
php artisan translatable:install
php artisan vendor:publish --provider="Laravel\\Ai\\AiServiceProvider" --tag=ai-config
translatable:install publishes:
config/translatable.php- package migrations
- the Bruno collection if you opt in
Manual setup is also supported:
php artisan vendor:publish --tag=translatable-config
php artisan vendor:publish --tag=translatable-migrations
php artisan vendor:publish --provider="Laravel\\Ai\\AiServiceProvider" --tag=ai-config
php artisan migrate
Configure at least one Laravel AI provider in config/ai.php or with environment variables such as OPENAI_API_KEY.
If you are upgrading from an older package version, publish migrations again before running php artisan migrate so new translation metadata columns are added.
Model Setup
Models must both use the trait and implement the package contract so they can be discovered by commands and API endpoints.
use Illuminate\Database\Eloquent\Model; use PictaStudio\Translatable\Contracts\Translatable as TranslatableContract; use PictaStudio\Translatable\Translatable; class Post extends Model implements TranslatableContract { use Translatable; public array $translatedAttributes = ['title', 'summary']; protected $fillable = [ 'slug', 'title', 'summary', ]; }
All translations live in a single translations table with:
translatable_typetranslatable_idlocaleattributevaluegenerated_byaccepted_at- timestamps
generated_by is set to user for user-written translations and ai for AI-generated ones. User-written translations are automatically accepted; AI-generated translations are stored with accepted_at = null.
Writing Translations
Use locale suffixes:
$post = Post::create([ 'slug' => 'welcome', 'title:en' => 'Welcome', 'title:it' => 'Benvenuto', 'summary:en' => 'A short intro', ]);
Use locale-keyed arrays:
$post = Post::create([ 'slug' => 'roadmap', 'en' => [ 'title' => 'Roadmap', 'summary' => 'Where the product is going.', ], 'it' => [ 'title' => 'Tabella di marcia', 'summary' => 'Dove sta andando il prodotto.', ], ]);
Use translation bags:
$post->translateOrNew('fr')->title = 'Bienvenue'; $post->translateOrNew('fr')->summary = 'Introduction courte'; $post->save();
If you set translatable.translations_wrapper, the model also accepts nested payloads under that key.
Reading Translations
The current app locale is used by default:
app()->setLocale('it'); $post->title; // Benvenuto $post->{'title:en'}; // Welcome
You can work with translation bags directly:
$post->translate('it'); $post->translateOrDefault('fr'); $post->translateOrNew('de'); $post->translateOrFail('en');
Other helper methods:
$post->hasTranslation('fr')$post->getTranslationValue('fr', 'title')$post->getTranslationsArray()$post->deleteTranslations()$post->deleteTranslations(['fr', 'de'])$post->replicateWithTranslations()$post->setDefaultLocale('fr')
setDefaultLocale() changes the locale used by that model instance without changing the application locale.
Fallbacks And Base Columns
Fallback behavior is driven by:
fallback_localeuse_fallbackuse_property_fallback
When enabled, attribute reads follow this order:
- Requested locale.
- Fallback locale, including language fallback for country-based locales such as
en-US -> en. - Base model column when the translated attribute also exists on the model table.
This means existing schemas such as products.name can keep working even before every translation is populated.
With sync_base_attributes=true, writing a translated value mirrors it into the matching base column when that column exists and the model is being created, or the base column is still empty. This is useful when legacy columns are non-nullable.
Serialization And Deletion
By default, toArray() includes translated attributes for the current locale. Disable that behavior with to_array_always_loads_translations=false if you want translation loading to stay fully explicit.
When delete_translations_on_delete=true, deleting a translatable model deletes its translations as well.
Locales Helper
The package registers PictaStudio\Translatable\Locales as both:
- the
translatable.localescontainer binding - the
Translatablefacade alias
It exposes the configured locale list and locale utilities:
use PictaStudio\Translatable\Locales; $locales = app(Locales::class); $locales->all(); // ['en', 'it', 'fr'] $locales->current(); // current locale $locales->fallback('en-US'); // 'en' when configured $locales->has('fr'); // true $locales->getCountryLocale('en', 'US'); // en-US
translatable.locales supports both flat and country-based configuration:
'locales' => [ 'en' => ['US', 'GB'], 'it', ],
That configuration produces en, en-US, en-GB, and it.
Locale Header Middleware
PictaStudio\Translatable\Middleware\SetLocaleFromHeader can be prepended to the HTTP kernel automatically.
When enabled, it reads the configured header and only switches locale if the value exists in translatable.locales.
Relevant config:
'register_locale_middleware' => true, 'locale_header' => 'Locale',
AI Translation
The package integrates with the Laravel AI SDK through PictaStudio\Translatable\Ai\ModelTranslator.
Programmatic usage:
use PictaStudio\Translatable\Ai\ModelTranslator; $summary = app(ModelTranslator::class)->translate($post, [ 'source_locale' => 'en', 'target_locales' => ['it', 'fr'], 'attributes' => ['title', 'summary'], 'force' => false, 'provider' => 'openai', 'model' => 'gpt-4.1-mini', ]);
Behavior:
- missing source values are skipped
- existing target translations are skipped unless
force=true - models of the same class are translated in shared AI batches
- translated values are saved with
generated_by=aiandaccepted_at=null
Artisan Commands
Translate one model class:
php artisan translatable:translate "App\\Models\\Post" \
--ids=1 \
--source-locale=en \
--target-locales=it \
--target-locales=fr \
--attributes=title \
--attributes=summary
Command behavior:
- omit the model argument to get an interactive search prompt
- omit
--source-localeto use the current app locale - omit
--target-localesto translate into every configured locale except the source locale - omit
--attributesto use every value in$translatedAttributes - add
--forceto overwrite existing translations - use
--providerand--ai-modelto override the Laravel AI defaults
Translate all currently missing translations across every discovered translatable model:
php artisan translatable:translate-missing \
--source-locale=en \
--target-locales=it \
--target-locales=fr
translatable:translate-missing only uses accepted source translations by default.
Queueing
API-triggered translations are queued through PictaStudio\Translatable\Ai\Jobs\TranslateModelsJob.
Queue config:
'ai' => [ 'source_locale' => null, 'provider' => null, 'model' => null, 'batch_size' => 25, 'queue' => [ 'connection' => env('TRANSLATABLE_AI_QUEUE_CONNECTION'), 'name' => env('TRANSLATABLE_AI_QUEUE_NAME', 'default'), ], ],
Completion Event
When queued translations finish, the package dispatches PictaStudio\Translatable\Events\AiTranslationsCompleted.
use Illuminate\Support\Facades\Event; use PictaStudio\Translatable\Events\AiTranslationsCompleted; Event::listen(AiTranslationsCompleted::class, function (AiTranslationsCompleted $event): void { $summary = $event->summary; $notifiable = $event->notifiable; // Send notifications, update UI state, write logs, trigger webhooks, ... });
HTTP API
The package registers its API routes by default. Disable them with:
'routes' => [ 'api' => [ 'enable' => false, ], ],
Default route config:
'routes' => [ 'api' => [ 'enable' => true, 'v1' => [ 'prefix' => 'api/translatable/v1', 'name' => 'api.translatable.v1', 'middleware' => ['api'], 'authorization' => [ 'header' => 'X-Translatable-Token', 'token' => env('TRANSLATABLE_AI_ROUTE_TOKEN'), 'ability' => null, 'using' => null, ], ], ], ],
Endpoints:
GET /api/translatable/v1/localesGET /api/translatable/v1/modelsGET /api/translatable/v1/missing-translationsPOST /api/translatable/v1/translate
GET /locales
Returns configured locales and marks the default locale. This endpoint does not use translation API authorization rules.
GET /models
Returns discoverable translatable models:
modelmorph_aliasattributes
Morph aliases come from Laravel's morph map when available. Otherwise the fully qualified class name is returned.
GET /missing-translations
Supported query parameters:
modelsource_localetarget_locales[]acceptedper_pagepage
model accepts either a fully qualified class name or a morph alias.
Rows are only returned when:
- at least one requested source value exists
- at least one target locale is missing a non-empty translation for that value
Response items contain:
model_typemodel_classmodel_idsource_localetarget_localestranslated_attributessource_valuesmissingmissing_count
accepted=true restricts the scan to accepted translation records. accepted=false restricts it to non-accepted translation records.
POST /translate
Example payload:
{
"model": "post",
"ids": [1, 2],
"source_locale": "en",
"target_locales": ["it", "fr"],
"attributes": ["title", "summary"],
"force": false,
"provider": "openai",
"model_name": "gpt-4.1-mini"
}
Notes:
- use
idfor one model oridsfor many modelaccepts a morph alias or class nameprovideroverridestranslatable.ai.providermodel_nameoverridestranslatable.ai.model- the response is
202 Acceptedafter the queue job is dispatched
API Authorization
Three authorization modes are supported for /models, /missing-translations, and /translate:
- shared token header
- Laravel Gate ability
- custom closure or invokable authorizer
Config example:
'authorization' => [ 'header' => 'X-Translatable-Token', 'token' => env('TRANSLATABLE_AI_ROUTE_TOKEN'), 'ability' => null, 'using' => null, ],
Rules:
- if
usingis set, it becomes the source of truth - otherwise, if
tokenis set, the request must provide the configured header - otherwise, if
abilityis set, the authenticated user must pass that ability for the target model class - if nothing is configured, route access is controlled only by the route middleware you assigned
Runtime registration example:
use Illuminate\Http\Request; use PictaStudio\Translatable\Http\RouteRequestAuthorizer; public function boot(): void { app(RouteRequestAuthorizer::class)->using( fn (Request $request, string $modelClass): bool => $request->user()?->can('translate-model', $modelClass) ?? false ); }
When authorization is configured, /models and /missing-translations automatically filter out models the current request is not allowed to access.
Scheduled Missing Translation Runs
The service provider can auto-register a scheduler entry for translatable:translate-missing.
'commands' => [ 'translate_missing' => [ 'enabled' => env('TRANSLATABLE_TRANSLATE_MISSING_ENABLED', false), 'schedule' => env('TRANSLATABLE_TRANSLATE_MISSING_SCHEDULE', '0 * * * *'), ], ],
When enabled, the package adds this command to Laravel's scheduler with the configured cron expression.
Configuration Reference
config/translatable.php exposes these feature flags and integration points:
locales: supported locales, including country-based definitionslocale_separator: separator used for country-based localeslocale: forces a package-level current locale when setfallback_locale: fixed fallback localeuse_fallback: enables locale fallback resolutionuse_property_fallback: enables attribute-level fallback resolutiontranslation_model: custom translation model classlocale_key: locale column name on the translation tabletranslations_wrapper: optional wrapper key for nested translation payloadssync_base_attributes: mirrors translated values into base columns when possibleto_array_always_loads_translations: controls whethertoArray()auto-includes translated attributesdelete_translations_on_delete: cascades translation deletion from the parent modelregister_locale_middleware: auto-registers the locale header middlewarelocale_header: request header read by the middlewareai.source_locale: default source locale for AI translationai.provider: default Laravel AI provider overrideai.model: default Laravel AI model overrideai.batch_size: maximum models per AI batchai.queue.connection: queue connection used by translation jobsai.queue.name: queue name used by translation jobsroutes.api.enable: enables or disables package API routesroutes.api.v1.prefix: API route prefixroutes.api.v1.name: API route name prefixroutes.api.v1.middleware: middleware stack applied to package routesroutes.api.v1.authorization.*: route authorization settingscommands.translate_missing.enabled: registers the scheduler entrycommands.translate_missing.schedule: cron expression for the scheduler entry
Bruno Collection
Publish the Bruno collection with:
php artisan vendor:publish --tag=translatable-bruno
Or publish it during setup with:
php artisan translatable:install