DEV Community

Cover image for How I've made a multilingual Laravel/WordPress website with Corcel (No Paid stuff)
Blair Jersyer
Blair Jersyer

Posted on

How I've made a multilingual Laravel/WordPress website with Corcel (No Paid stuff)

Hi, this should be my first post here, so a hundred apologies if it's not well-formatted. As the topic of this tutorial mentions, I've created a multilingual WordPress website using Corcel and some Laravel packages. While I could have made a website only with WordPress, I'll rather say I'm really familiar with Laravel and I like the tools it offers (Telescope, Queues, Tasks Scheduling...), things which are not that easy with WordPress.

This website is CodeWatchers. So let's dive into how I did to make it happen without external plugin and completely free. I cannot dive into step by step how I did it (the app has been made in 2 months) but I'll cover the main steps.

Required Tools

There is a bunch of required tools here, let me list them and explains why I've used that:

  • Laravel: obviously because I like Laravel
  • WordPress: because the Gutenberg Editor is just dope for me + it already has authentication and user management system.
  • Stichoza/google-translate: used to make the translation
  • TailwindCSS: I'm not a pro designer, but that tool makes me feel like I'm one.
  • A custom WordPress Plugin: something I did for an internal purpose (I'll explain furthermore later).
  • DomQuery: Useful to manipulate dom on the server end. Why? our posts have various 2 sections, there is single post content and the list content. Since we're doing affiliation reviews, that application should be able to fetch list content to create such post automatically and single post content while a deep review is requested.
  • Laravel Sitemap by Spatie: We need to create a sitemap for every language. That package is 100% useful.
  • Response Cache by Spatie: caching a response makes the website render output quicker = fast website.
  • Translated String Exporter: that will parse your code and extract localization text into json files (very useful if you want to use the VSCode extension I'm sharing down here).

That's basically all. Regarding the translation tool, there are a bunch of them available on Github so, you're not forced to use the one I've mentioned.

Design Steps

It's also important to understand how the application works to justify why the above tools are used.

1 - Creating the WordPress plugin

The created plugin actually allows defining some metadata for each post created (language). With Gutenberg, it's easy to add tailwind utility classes to Group and block.

Alt Text

2 - Saving Post to translate on a queue file

Now every time a post is published, with the action "save_post"
and "post_updated" the plugin record the ID of the post into a JSON file. We have created 3 types of files (queue files) :

  • Pending Translation Posts
  • Ongoing Translation Posts
  • Completed Translation Posts

Alt Text

We then need to keep on that plugin the available languages as there should be a reference on all queue files.

3 - Creating Task Scheduling

Task scheduling here will parse the queue files and run a translation process every time there is a new post to translate.

app/Kernel.php

/**
* Define the application's command schedule.
*
* @param  \Illuminate\Console\Scheduling\Schedule  $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
    if ( env( 'TRANSLATION_ENABLED', true ) ) {
        $this->dateInstance     =   now();

        collect( array_keys( config( 'codewatchers.languages' ) ) )->each( function( $lang ) use ( $schedule ) {
            $this->dateInstance     =   $this->dateInstance->addSeconds(60); // add a 1 minute timespan.

            $this->translatePosts( $schedule, $lang, $this->dateInstance->copy() ); // this will parse the queue files.
        });
    }
}

Now, let's see how the queue files are parsed.

app/Kernel.php

protected function translatePosts( $schedule, $lang, $time )
{
    $translationService     =   new CodeWatchersService;
    $ongoing                =   $translationService->getPostsStatus( 'ongoing', $lang );
    $pending                =   $translationService->getPostsStatus( 'pending', $lang );
    $translated             =   $translationService->getPostsStatus( 'translated', $lang );

    if ( count( $pending ) > 0 && ! empty( $pending ) ) {
        if ( ! in_array( $pending[0], $ongoing ) ) { // just the first reference on the queue file.

            $translationService->addToOngoing( $pending[0], $lang ); // change the status of the reference (post).

            $schedule->call( function() use ( $lang, $pending, $time ) {
                TranslateReviewJob::dispatch( $lang, $pending[0] )
                    ->delay( $time );
            })->everyMinute(); // dispatch the translation.

        } else {

            /**
                * remove it from pending
                */
            $translationService->addToOngoing( $pending[0], $lang );

            Log::info( 'Post to translate found. But already being processed.', compact( 'pending', 'translated', 'ongoing' ) );
        }
    } else {
        Log::info( 'Nothing to translate. No scheduled task', compact( 'pending', 'translated', 'ongoing' ) );
    }
}

4 - Translation

CodeWatchersService is a class that helps to make the translation. The key point to note here is while doing translation with Google, if you send the DOM, you might end up with incorrect HTML Markup. So basically with DomQuery, I can extract paragraphs and links' content to translate individually.

If links are within a paragraph, I replace the links with a token (which can't be changed in any kind by Google, so usually numbers can go here). These token are replaced by translated links once the paragraph is translated. It's not perfect but at least I keep the DOM structure.

This method I'm about to share shows how the progressive dom translation is performed.

app/Services/CodeWatchers.php

public function progressiveDomTranslation( $content, $lang, $source = '' )
{
    dump( 'Translation : Dom Initialization - ', $source );

    $dom                    =   new DomQuery( $content );
    $linksBag               =   [];

    dump( 'Translation : Backup Link' );

    collect( $dom->find( 'a' ) )->each( function( &$domRef ) use ( &$linksBag ) {
        $reference                  =   rand(100000000,999999999);
        $linksBag[ $reference ]     =   $domRef->prop('outerHTML');
        $domRef->replaceWith( '<a class="replacement-reference">' . strtoupper( $reference ) . '</a>' );
    });

    dump( 'Translation : Translate P, H2' );

    collect([ 'p', 'h2', 'h3' ])->each( function( $tagName ) use ( $source, $lang, $dom ) {
        $tags       =   collect( $dom->find( $tagName ) );
        $total      =    $tags->count();
        $tags->each( function( $tag, $index ) use ( $source, $lang, $total, $tagName ) {
            dump( 'Translation : tag "' . $tagName .'" ' . ( $index + 1 ) . '/' . $total );
            if ( ! empty( $tag->text() ) ) {
                $googleTranslate        =   $this->translateInstance( $lang );
                $googleTranslate->setSource( $source );
                $tag->text( ucfirst( $googleTranslate->translate( $tag->text(), $lang ) ) );
                sleep(1);
            }
        });
    });

    dump( 'Translation : Restore links' );

    $dom    =   $dom->getOuterHtml();

    collect( $linksBag )->each( function( $domValue, $reference ) use ( &$dom ) {
        $dom = str_replace( strtoupper( $reference ), $domValue, $dom );
    });

    dump( 'Translation : Translate Links' );

    $dom    =   new DomQuery( $dom );

    collect([ 'a', 'li' ])->each( function( $tagName ) use ( $source, $lang, $dom ) {
        $totalTags      =   collect( $dom->find( $tagName ) );
        $countTags      =   $totalTags->count();
        $totalTags->each( function( $tag, $index ) use ( $source, $lang, $tagName, $countTags ) {
            dump( 'Translation : tag "' . $tagName .'" ' . ( $index + 1 ) . '/' . $countTags );
            if ( ! empty( $tag->text() ) ) {
                $googleTranslate        =   $this->translateInstance( $lang );
                $googleTranslate->setSource( $source );
                $tag->text( ucfirst( $googleTranslate->translate( $tag->text(), $lang ) ) );
                sleep(1);
            }
        });
    });

    return $dom;
}

I'm making the process to sleep for 1 second after each tag translation. We should avoid sending too many requests to Google (you'll be banned if you overpass the threshold, for those using the free translation tool).

5 - Building Multilingual Routing with Laravel

Obviously, we should have a multilingual routing with Laravel. That will involve looping over the existing languages stored on a config file :

config/codewatchers.php

return [
    'languages'     =>  [
        'en'    =>  'English',
        'fr'    =>  'Français'
    ]
]

Here is how the route is defined.

routes/web.php

$languages  =   array_keys( config( 'codewatchers.languages' ) );

foreach( $languages as $locale ) {
    Route::prefix( $locale )
        ->middleware([ 'cw.detect.locale', 'cw.hash' ])
        ->group( function( $group ) use ( $locale ) {

            App::setLocale( $locale );

            Route::get( '', 'HomeController@index' )->middleware('cacheResponse:600');
            Route::get( '/' . Str::slug( __( 'review' ) ) . '/{slug}', 'HomeController@single' )->middleware('cacheResponse:600');
            Route::get( '/' . Str::slug( __( 'List' ) ) . '/{slug}', 'HomeController@singleList' )->middleware( 'cacheResponse:600' );
            Route::get( '/' . Str::slug( __( 'tag' ) ) . '/{tags}', 'HomeController@tags' )->middleware('cacheResponse:600')->name( $locale . '.tag' );
            Route::get( '/' . Str::slug( __( 'category' ) ) . '/{category}', 'HomeController@getCategory' )->middleware('cacheResponse:600')->name( $locale . '.category' );
            Route::get( '/' . Str::slug( __( 'Week Trends' ) ), 'HomeController@getTrends' )->middleware('cacheResponse:600')->name( $locale . '.week-trends' );
            Route::get( '/' . Str::slug( __( 'Recommended' ) ), 'HomeController@getRecommended' )->middleware('cacheResponse:600')->name( $locale . '.recommended' );
            Route::get( '/' . Str::slug( __( 'Recent Reviews' ) ), 'HomeController@getLatests' )->middleware('cacheResponse:600')->name( $locale . '.recent-reviews' );
            Route::get( '/' . Str::slug( __( 'Search' ) ), 'HomeController@searchReviews' );
            Route::get( '/' . Str::slug( __( 'Error' ) ) . '/404', 'HomeController@show404' ); // harcode http code
    });
}

Obviously I want the URL to be translated as well. So I'm using the Utility Str to convert a translated string into a slug. You might need to learn how translation works on Laravel. If you're working with VsCode, there is a plugin that can help you to translate your files : Vscode Google Translate by funkyremi. This tool is very useful if you want to translate some sections of your application that are static.

6 - Store User Locale

Okay, the website is running, now we need to store the user preferred language. You can do that with a middleware that stores cookies after the user access the website using a specific language.

*app/Http/Middleware/DetectLocale.php

class DetectLocale
{
    /**
     * Handle an incoming request.
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @return mixed
     */
    public function handle($request, Closure $next)
    {
        $locale     =   substr( request()->route()->getPrefix(), 1 );

        App::setLocale( $locale );

        $minutes    =   now()->diffInMinutes( now()->addMonths(12) );

        Cookie::queue( 
            Cookie::make( config( 'codewatchers.cookie.name' ), $locale, $minutes ) 
        );

        return $next($request);
    }
}

7 - Design with TailwindCSS

Okay, I won't cover this section as it all depends on your personal taste. Tailwind CSS is however very easy to use (after the setup which might look complicated). If you're looking for some inspiration, you can head to Dribbble you'll have some interesting website design to inspire you.

8 - Using Schema.org

This is totally optional, but if you do want Google (or any other Search Engine) to "understand" your content, you should consider using that.

Final Words

Okay, I haven't covered all the points to craft that application, as that will require more than one tutorial. However, these are the required steps I've been through to make that application multilingual and for free. For the sitemap generator, it's useful to make sure the content can be easily indexed in all languages. I've made this post to share my knowledge and I might be wrong on some parts, but I hope it could inspire you to start yours. I'm a french speaking guy so excuse my mistakes.

Cheers.

Top comments (0)