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

Ink Translatable Laravel Package

ink/ink-translatable

Ink Translatable is a Laravel package for adding multilingual fields to Eloquent models. Store and retrieve translations per locale with a simple, fluent API, fallback support, and easy integration into existing models—ideal for multilingual apps without duplicating database columns.

View on GitHub
Deep Wiki
Context7

Getting Started

Reality check before you start: ink/ink-translatable is a Laravel 4–era package with no stars and no dependents. That means no community docs, no Stack Overflow answers, and no one else hitting the bugs first. Read the source in vendor/ink/ink-translatable/src before you write a line of application code — the README will be thin, and the trait + translation model are the real specification. Also confirm your runtime: Laravel 4 is EOL and capped at PHP 5.6, so this package only makes sense if you are maintaining an existing L4 app.

Minimal setup:

  1. composer require ink/ink-translatable — if it isn't on Packagist anymore, you'll need a VCS repository entry pointing at the GitHub repo, which is itself a signal about the package's health.
  2. Register the service provider in app/config/app.php under providers:
    'Ink\Translatable\TranslatableServiceProvider',
    
  3. Publish and run whatever the package ships:
    php artisan config:publish ink/ink-translatable
    php artisan migrate --package="ink/ink-translatable"
    
  4. Open app/config/packages/ink/ink-translatable/config.php and check two things immediately: the locales list and the name of the translations table. Everything else you do will depend on those.

First use case: take an existing monolingual posts table (title, body) and make title and body translatable.

// app/models/Post.php
class Post extends Eloquent
{
    use Ink\Translatable\Translatable;

    protected $table = 'posts';

    // Name the attributes the package should route through the translations store.
    protected $translatable = array('title', 'body');
}

Then verify in php artisan tinker before touching controllers:

$post = Post::create(array('title' => 'Hello', 'body' => 'World'));
$post->translate('fr')->title = 'Bonjour';
$post->save();

App::setLocale('fr');
echo $post->fresh()->title; // should be "Bonjour"

If that round-trip works, the package is wired correctly and everything after this is application design. If it doesn't, the problem is almost always the trait's expected property names ($translatable vs $translatedAttributes vs a method override) — confirm them against the source, not the README.

Implementation Patterns

Fallback resolution lives in one place

Don't sprinkle App::setLocale() around controllers. Pick a single resolver — a route filter in app/filters.php — and let every read go through the package's fallback chain:

Route::filter('locale', function ($route, $request) {
    $locale = $route->parameter('locale') ?: Session::get('locale', Config::get('app.locale'));

    if (! in_array($locale, Config::get('app.available_locales'))) {
        return Redirect::route('home');
    }

    App::setLocale($locale);
    Session::put('locale', $locale);
});
Route::group(array('prefix' => '{locale}', 'before' => 'locale'), function () {
    Route::resource('posts', 'PostController');
});

Keep app.fallback_locale (Laravel 4.2) aligned with the package's own fallback config. Two different fallback settings with the same name is the single easiest way to produce "why is this field English on the French page" bugs.

Writing: treat the translation set as one form submission

The natural admin UI is per-locale tabs, one submit button. Validate the whole payload, then loop the locales — this keeps the write path honest and makes partial saves obvious:

public function update($locale, $id)
{
    $post = Post::findOrFail($id);
    $input = Input::all();

    $rules = array();
    foreach (Config::get('app.available_locales') as $l) {
        $rules[$l.'.title'] = 'required|max:200';
        $rules[$l.'.slug']  = 'required|alpha_dash|unique_translation:posts,slug,'.$l.','.$post->id;
    }

    $v = Validator::make($input, $rules);
    if ($v->fails()) {
        return Redirect::back()->withErrors($v)->withInput();
    }

    foreach ($input as $l => $fields) {
        if (! is_array($fields)) continue;
        $t = $post->translate($l);
        foreach ($fields as $key => $value) {
            $t->$key = $value;
        }
    }

    $post->save();
    return Redirect::route('admin.posts.edit', $post->id);
}

Note the custom unique_translation rule — the stock unique rule only sees the base table, so slug collisions across locales will slip through unless you query the translations table yourself.

Reading: eager load or you'll N+1 on every index page

An index of 25 posts with 3 locales and 2 fields is a plausible 150 queries if translations are lazy-loaded. Load them up front and let the package hydrate:

$posts = Post::with('translations')->paginate(25);

If the package doesn't expose a translations() relation name you can with(), add one to your model (hasMany to the translation model) and use it — the trait will reuse the already-loaded collection. Verify with DB::listen() in app/start/global.php that the page stays at two queries.

Querying by translated content

Filtering Post::where('title', 'like', '%foo%') hits the base table, which for a translatable model is either empty or stale. Either query the translation model directly, or keep a denormalized fallback column on the base table for search:

$ids = PostTranslation::where('locale', App::getLocale())
    ->where('title', 'like', '%'.$q.'%')
    ->lists('post_id');

$posts = Post::whereIn('id', $ids)->get();

Full-text search across locales is the point where most teams give up and put a search index (Sphinx/Elasticsearch) in front. Decide early whether you need it.

Serialization for an API

toArray() on the parent model will either omit translations or dump every locale at once. Wrap it:

class PostTransformer
{
    public function transform(Post $post)
    {
        return array(
            'id'    => $post->id,
            'title' => $post->title,   // resolved to App::getLocale()
            'body'  => $post->body,
            'locale' => App::getLocale(),
        );
    }
}

For a CMS that needs to edit all locales, expose a second endpoint returning $post->translations keyed by locale. Don't try to make one payload do both jobs.

Seeding and imports

Seeding is where translation packages usually break, because create() may or may not accept nested locale keys. Be explicit in seeders — create the model in the default locale, then loop the rest through translate():

$post = Post::create(array('title' => 'Hello', 'body' => '...'));
foreach (array('fr', 'de') as $locale) {
    $post->translate($locale)->title = $title[$locale];
}
$post->save();

Testing

Write one test per locale that asserts the read path, not the write path — the write path is the package's job, the read path (fallback, caching, current locale) is yours. In Laravel 4 that's a plain PHPUnit test bootstrapping the app and calling App::setLocale() before assertions.

Gotchas and Tips

The ecosystem risk dominates everything else. Laravel 4, no stars, no dependents, no recent commits. Before you build a content model on top of this, estimate the cost of swapping it for dimsav/laravel-translatable or astrotomic/laravel-translatable on a supported Laravel version. If you're on L4 for another two years, fine — but keep the trait usage thin (one place per model, no package-specific calls in views or controllers) so the exit is a mechanical change rather than a rewrite. Don't let Ink\Translatable\* class names leak past the model layer.

Empty string vs. missing translation. Decide one behavior and enforce it. "Field is blank in French" and "field was never translated to French" look identical to users but behave differently in a fallback chain. Most of these packages treat null as missing and '' as present-but-empty; if your editors submit empty text inputs, you'll get '' and lose the fallback silently. Normalize on save: convert '' to null for translatable fields, then let the fallback do its job.

Mass assignment. The trait often bypasses $fillable for translated attributes, or conversely refuses them. Test Post::create(array('title' => 'x')) explicitly. If translated attributes aren't fillable, add them to $fillable even though they aren't real columns — a subtle source of "the form saved but nothing changed."

Locale column length and collation. If the translations table uses varchar(2) for locale, en_US/pt_BR will silently truncate or error depending on MySQL strict mode. Check the migration and widen it to at least 5 before you have data.

No cascade delete. Eloquent won't fire deletes on related rows in L4. Either add ON DELETE CASCADE to the FK in your own migration or delete translations in an eloquent.deleting hook in app/start/global.php:

Post::deleting(function ($post) {
    $post->translations()->delete();
});

Orphaned translation rows are the most common form of silent rot in these setups.

Cache invalidation. If you cache rendered pages keyed only by URL, App::setLocale() in a filter will happily serve the French page's HTML to English visitors. Key the cache on $locale explicitly — 'post.'.$id.'.'.App::getLocale() — and bump it on save.

Debugging a "translation didn't save" report. In order: (1) is the attribute listed in the translatable property? (2) was save() called on the parent after mutating the translation object? (3) did the locale string match the config array exactly, including case? (4) does a row exist in the translations table? Dump with DB::listen(function($sql, $bindings){ Log::info($sql.' | '.json_encode($bindings)); }) and watch the insert. Nine times out of ten it's step 2 — mutating a translation object returns a different instance and forgetting the parent save() produces no error at all.

Extension points worth knowing. The translation model is usually swappable via config or by overriding a translations() / newTranslation() method on your model — that's your hook for adding a status column (draft/published per locale), an updated_by, or a review workflow. Doing it there rather than in the base model keeps the package's hydration logic intact.

Watch for toArray() surprises in JSON responses. Some of these packages override toArray() to merge translations into the parent attributes; others return a nested translations key. Confirm which before you ship an API contract, because switching later is a breaking change for your clients.

Don't translate everything. Slugs, SEO metadata, and category names usually want one canonical value, not per-locale copies — a per-locale slug means every URL change is multiplied by your locale count, and cross-locale links become fragile. Reserve the trait for prose fields and keep identifiers monolingual.

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.
abbasudo/artisan-route
mirvan/ippanel
ignibyte/aic-framework
glavweb/data-schema-bundle
glavweb/datagrid-bundle
tacticmedia/scim-server
uni-method/frame
neika-ai/sdk
adachsoft/php-code-style
pharaonic/laravel-notification-dependencies
beautinow/translug
dev-brainiac/bot-cloaked
octoberrain/html
octoberrain/extension
octoberrain/exception
octoberrain/contracts
adachsoft/api-version-bundle
cgv/localized-resource
noith/settings
kda/laravel-package