Laravel Nova is cool package to quickly create admin interfaces in a Laravel app. Unfortunately there's no support for multiple locales out of the box. A while ago we published a package called nova-translatable that makes any of the built in field types translatable.

Using the package #

In order to use nova-translatable you must install spatie/laravel-translatable into your Laravel app. In a nutshell, laravel-translatable will store translations for your model in a json column in your table.

You can let nova-translatable know which locales it should use by calling defaultLocales. You can put this code in AppServiceProvider or a dedicated service provider of your own:

\Spatie\NovaTranslatable\Translatable::defaultLocales(['en', 'nl']);

Let's now take a look at simple Nova resource.

public function fields(Request $request)
{
    return [
        ID::make()->sortable(),
        Text::make('title'),
        Trix::make('text'),
    ];
}

In order to make the title and text fields translatable, the only thing you need to do is to pass them to an instance of Spatie\NovaTranslatable\Translatable.

public function fields(Request $request)
{
    return [
        ID::make()->sortable(),

        Translatable::make([
            Text::make('title'),
            Trix::make('text'),
        ]),
    ];
}

With this in place, this is how your Nova resource will look like:

nova-translatable in action

Behind the scenes #

There's actually not so much code involved to make nova-translatable work. The main Translatable class is only 140 lines long. Translatable extends Nova's native MergeValue class which is used to group fields (a Nova Field Panel is a MergeValue tool).

An instance of MergeValue holds its fields in the data property. Whenever a new Translatable is newed up, we are going to call a function named createTranslatableFields which will replace that data. Let's take a look at that function.

protected function createTranslatableFields()
{
    if ($this->onIndexPage()) {
        $this->data = $this->originalFields;
        return;
    }

    $this->data = [];

    collect($this->locales)
        ->crossJoin($this->originalFields)
        ->eachSpread(function (string $locale, Field $field) {
            $this->data[] = $this->createTranslatedField($field, $locale);
        });
}

If we're on the index, aka the list representation of a resource, we won't replace the original fields. We assume you just want to original fields there. But if we're on any other screen of the resource we will start replacing the data. For each combination of a field and of the desired locales we will create a new entry in data. If you want some more info on crossJoin and eachSpread, read this post.

Here's the code of createTranslatedField. I've added some comments so you can understand better what's going on.

protected function createTranslatedField(Field $originalField, string $locale): Field
{
    /*
     * Let's start by clone the original field so we can make some
     * modifications for the given $locale
     */
    $translatedField = clone $originalField;

    /*
     * To make this work, the attribute most be an attribute that is actually on
     * the underlying model. You don't need to understand why this happens. Hopefully
     * this will not be needed anymore in future version of Nova.
     *
     * More info in this issue on Nova:
     * https://github.com/laravel/nova-issues/issues/827
     */
    $originalAttribute = $translatedField->attribute;
    $translatedField->attribute = 'translations';

    /*
     * If your app uses more than 1 locale we're going to change the label next to it a little
     * By default `displayLocalizedNameUsingCallback` contains a closure that will append
     * the locale between brackets to the label, so `name` will become `name (en)`.
     */
    $translatedField->name = (count($this->locales) > 1)
        ? ($this->displayLocalizedNameUsingCallback)($translatedField, $locale)
        : $translatedField->name;

    /*
     * `resolveUsing` will get called before the field gets displayed. It should return the value
     * of the form field being displayed
     */
    $translatedField
        ->resolveUsing(function ($value, Model $model) use ($translatedField, $locale, $originalAttribute) {
            /*
             * Here we sneakily update the `attribute`. It will get used as the `id` of the form field 
             * that Nova will render. When the resource gets saved this name will be used in the response.
             */
            $translatedField->attribute = 'translations_'.$originalAttribute.'_'.$locale;

            /*
             * Here we will return the translation of the $locale we're processing. This value
             * will get displayed in the form field that Nova will render.
             */
            return $model->translations[$originalAttribute][$locale] ?? '';
        });

    /*
     * `fillUsing` will get called when the user submitted the form. It contains logic on how
     * the response values should get mapped to the underlying model.
     */
    $translatedField->fillUsing(function ($request, $model, $attribute, $requestAttribute) {
        /*
         * Remember that `attribute` we set `resolveUsing`? Here we are going to split it up
         * again so we know which field and which locale we should store the value from the request.
         */
        $requestAttributeParts = explode('_', $requestAttribute);
        $locale = array_last($requestAttributeParts);

        array_shift($requestAttributeParts);
        array_pop($requestAttributeParts);

        $key = implode('_', $requestAttributeParts);

        $model->setTranslation($key, $locale, $request->get($requestAttribute));
    });

    return $translatedField;
}

In closing #

You might wonder why we didn't render the translatable fields in tabs, panels or with magical unicorns displayed next to them. The truth is that everybody wants translations to be displayed a bit different. That's why we opted to keep it very simple for now. If Nova gains the ability to better structure a long form natively, we'd probably start leveraging that in a new major version of the package.

If you like nova-translatable be sure to check out or other Nova packages as well:

Even if you're not using Laravel Nova, you probably will find something for your next project in this list of packages the team at Spatie has made previously.