Repository: https://github.com/Light-it-labs/tallstackjobs


Introduction

Laravel Livewire has undoubtedly had an impressive growth thanks to how intuitive it became to handle view behaviors from a simple PHP class. But sometimes, we need small particular behaviors in the views of our applications - This is when AlpineJS appears.

Some time ago the TALL Stack began to appear, a perfect combination for us developers, all the power of Laravel and Livewire, added to TailwindCSS and AlpineJS.

This time I'll be showing you how easy it is to start a new project with this stack and explaining how AlpineJs works.

We will venture into implementing a job board, where we can list different jobs, filter them according to a keyword, and see them paginated. In this guide, you'll see how easy it is to work with these 4 technologies together.

The Project

The Basic Setup

As you can imagine, every Laravel project starts with this command:

composer create-project --prefer-dist laravel/job-board

After this, navigate to the folder created by the previous command using:

cd job-board

Make sure that the .env file is configured properly, specially that the database configuration is valid.

Now it's time to install and configure the TALL Stack. Run the next commands:

composer require livewire/livewire laravel-frontend-presets/tall

php artisan ui tall --auth

The first one installs Livewire and all the TALL Stack dependencies such as AlpineJS and TailwindCSS, and the second one initializes an entire authentication system in our app. Yes, this means you don't need to implement an authentication system. This one will use all the stuff provided by Laravel to authenticate users. Of course you can give your style to these views, but in this opportunity we will use the default TALL Stack markup.

Run the standard commands to compile the project assets:

npm install && npm run watch

and run php artisan serve

After running the last command, the project will be available at http://localhost:8000. Go there and verify that everything is ok. Now it's time to start adding some custom stuff to the project. We'll start with creating classes such as models, migrations and factories.

The next command will create a new migration for the Job model:

php artisan make:migration create_jobs_table

The content of the migration needs to be like the following one:

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

class CreateJobsTable extends Migration
{
    public function up()
    {
        Schema::create('jobs', function (Blueprint $table) {
            $table->id();

            $table->string('name');
            $table->string('email');
            $table->double('salary');
            $table->string('currency')->default('USD');
            $table->text('description');

            $table->timestamps();
        });
    }

    public function down()
    {
        Schema::dropIfExists('jobs');
    }
}
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

Of course you can add or modify the columns of the migration in your own way.

Now, you will need a model to query that table, so it's time to run the next command:

php artisan make:model Job

Go to the created model in App/Models/Jobs and make sure that match the next content:

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Job extends Model
{
    use HasFactory;

    protected $fillable = [
        'name', 'description', 'currency', 'salary', 'email'
    ];
}

As you can see, the class uses the trait HasFactory; this is a new way to create fake instances of the model introduced in Laravel 8. The next step is creating fake data:

php artisan make:factory JobFactory

and after doing it, open the file located at database/factories/JobFactory.php

<?php

namespace Database\Factories;

use App\Models\Job;
use Illuminate\Database\Eloquent\Factories\Factory;

class JobFactory extends Factory
{
    protected $model = Job::class;

    public function definition()
    {
        return [
            'name' => $this->faker->jobTitle,
            'description' => $this->faker->text(200),
            'currency' => 'USD',
            'salary' => $this->faker->numberBetween(200, 800),
            'email' => $this->faker->unique()->safeEmail,
        ];
    }
}
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

The last step of this section is to create fake data from the root entry of the database seeder. To do this, open the database/seeders/DatabaseSeeders.php file.

<?php

namespace Database\Seeders;

use Illuminate\Database\Seeder;
use App\Models\Job;
use App\Models\User;

class DatabaseSeeder extends Seeder
{
    public function run()
    {
        User::create([
            'name' => 'User',
            'email' => 'user@user.com',
            'password' => bcrypt('password'),
        ]);

        Job::factory(50)->create();
    }
}
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

As you can see, in addition to creating jobs, we're creating a user to authenticate to the application and, therefore, access routes that are protected.

Livewire and AlpineJS: The real reason you are here

Now it's time to get into Livewire and AlpineJs. For this we will need a Livewire component that is in charge of listing all the available job offers. Run the next command:

php artisan make:livewire ShowJobs

After that, a new component as a php class will be available at app/Http/Livewire/ShowJobs.php

<?php

namespace App\Http\Livewire;

use App\Models\Job;
use Livewire\Component;
use Livewire\WithPagination;

class ShowJobs extends Component
{
    use WithPagination;

    public $search = '';

    protected $queryString = ['search'];

    public function updatingSearch()
    {
        $this->resetPage();
    }

    public function render()
    {
        return view('livewire.show-jobs', [
            'jobs' => Job::where('name', 'like', '%'.$this->search.'%')->paginate(5)
        ]);
    }
}

Here we can see two functions; the last one, called render, is self-descriptive. It's a function that is executed each time a property changes or action is executed over the component view. It automatically re-renders the view with the new data and dispatches it via AJAX to the client.

The updatingSearch method is executed each time the search property changes. In this method, we only need to reset the current pagination page.

The protected queryString property is in charge of keeping the search query in the current location query object. Whenever it changes, the query param will be updated; this is useful because we can directly access a specific search result through a simple link.

We now need to link the ShowJobs component to a Route by adding the next line in routes/web.php

<?php

use App\Http\Livewire\ShowJobs;

...
...

Route::get('/jobs', ShowJobs::class)
    ->name('jobs')
    ->middleware('auth');

...
...
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

As you noticed before, the render function of the ShowJobs component returns a view:

return view('livewire.show-jobs', [
  'jobs' => Job::where('name', 'like', '%'.$this->search.'%')->paginate(5)
]);
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

Therefore, we'll need to define the view 'livewire.show-jobs'. If you go to resources/views/livewire you will see a file called show-jobs.blade.php, and it is precisely here where we define the view markup of the component.

You may be wondering when this view was created; the answer is easy, it was created when you ran the command that creates the ShowJobs component.

Anyways, the content of the view should match the next:

<?ph<div x-data="{ current: null, showModal: false }">
    <div class="w-full flex justify-center px-32 mb-5">
        <input wire:model="search" id="search" name="search" type="text" class="appearance-none block w-96 px-3 py-2 m-5 border border-gray-300 rounded-md placeholder-gray-400 focus:outline-none focus:shadow-outline-blue focus:border-blue-300 transition duration-150 ease-in-" placeholder="Search query ..
    </div>

    <div class="bg-white min-w-0 flex-1 px-32 mb-5">
        <template x-for="(job, index) in {{ json_encode($jobs->toArray()['data']) }}" :key="index">
            <div class="border-b border-gray-100 p-5 cursor-pointer" x-on:click="current = job; showModal = true">
                <h2 class="font-medium text-xl" x-text="job.name"></h2>
                <p x-text="job.description"></p>
            </div>
        </template>
    </div>

    <div class="bg-white min-w-0 flex-1 px-32 mb-5">
        {{ $jobs->links() }}
    </div>

    <template x-if="showModal && current">
        <div class="fixed z-10 inset-0 overflow-y-auto">
            <div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center">

                <div class="fixed inset-0 transition-opacity" aria-hidden="true">
                    <div class="absolute inset-0 bg-gray-500 opacity-75"></div>
                </div>

                <div class="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all" role="dialog" aria-modal="true" aria-labelledby="modal-headline" @click.away="showModal = false">
                <div class="">
                    <div class="mt-3 text-center">
                    <h3 class="text-lg leading-6 font-medium text-gray-900" id="modal-headline">
                        <p x-text="current.name"></p>
                    </h3>
                    <div class="mt-2">
                        <p class="text-sm text-gray-500" x-text="current.description"></p>
                    </div>
                    </div>
                </div>
                <div class="mt-5">
                    <button @click="showModal = false" type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-indigo-600 text-base font-medium text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500">
                    Close
                    </button>
                </div>
                </div>
            </div>
        </div>
    </template>
</div>p
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

We are now arriving to most interesting part of the blog,  where we use AlpineJS and take advantage of the simplicity that it provides. In the previous code, you can see directives like x-data , x-on:click , x-for, x-text, x-if .

These directives are provided by AlpineJS - Luckily they are self descriptive. The x-data directive is used to define the component data and the component scope. Everything inside the label that contains x-data is a component. Of course, we can have as many nested components as we want by specifying x-data directive.

The first line starts defining x-data="{ current: null, showModal: false }" , this means that each thing inside the first div tag is a component, and that component has a state defined by { current: null, showModal: false } . The current state variable will be the clicked job offer, and the showModal will be the property that handles the state of the modal with additional information on the job offer.

In the next block of markup we can see the x-for directive; we use it to iterate over the items of the current page of the list of jobs. The x-for directive, like the x-if , works only over template tags. Inside the x-for we can see the use of two x-text directives, which are in charge of injecting text to that HTML tags.

Continuing to the next block, which is the block in charge of rendering the modal with additional job information, we see the x-if directive. I repeat once more, this directive, just like the x-for directive, works only over template tags. Here we can access the 2 properties, part of the state 'current' and 'showModal' because we are in the same scope of the specified component.

The property showModal will be true when the user clicks on a job offer. You'll be able to see that this event is captured in line number 8;  There, you can see the x-on:click  directive. On this line, we'll set the current job (the clicked job) and after that, we'll show the modal changing the showModal property to true. Magically, AlpineJS, based on the reactivity concepts, will display the modal.

The project layout

I know that this is just a guide, but why leave out design improvements?

Fundamentally, we'll need to modify one file for this. Open the resources/views/layouts/base.blade.php file and, after that, the content of the body should match the next markup:

<body>
        <div class="relative min-h-screen flex flex-col">
            <nav class="flex-shrink-0 bg-indigo-600">
                <div class="max-w-7xl mx-auto px-8">
                    <div class="relative flex items-center justify-between h-16">
                        <div class="flex items-center px-2">
                            <div class="flex-shrink-0">
                                {{-- <img class="h-8 w-auto" src="https://tailwindui.com/img/logos/workflow-mark-indigo-300.svg" alt="Workflow"> --}}
                            </div>
                        </div>

                        <div class="block w-80">
                            <div class="flex items-center justify-end">
                                <div class="flex">
                                    @auth
                                        <a href="{{ route('jobs') }}" class="px-3 py-2 rounded-md text-sm font-medium text-indigo-200 hover:text-white">Jobs</a>
                                        <a
                                            href="{{ route('logout') }}"
                                            onclick="event.preventDefault(); document.getElementById('logout-form').submit();"
                                            class="px-3 py-2 rounded-md text-sm font-medium text-indigo-200 hover:text-white"
                                        >
                                            Log out
                                        </a>

                                        <form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
                                            @csrf
                                        </form>
                                    @else
                                        <a href="{{ route('login') }}" class="px-3 py-2 rounded-md text-sm font-medium text-indigo-200 hover:text-white">Login</a>
                                        @if (Route::has('register'))
                                            <a href="{{ route('register') }}" class="px-3 py-2 rounded-md text-sm font-medium text-indigo-200 hover:text-white">Register</a>
                                        @endif
                                    @endauth
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </nav>

            @yield('body')
        </div>

        @livewireScripts
    </body>
database/migrations/xxxx_xx_xx_xxxxxx_create_products_table.php

Conclusion

Undoubtedly, Tall Stack is an excellent choice, and we can get a lot out of the libraries it combines. On the one hand, we can use Livewire for the backend, taking advantage of object-oriented programming characteristics by defining classes that control views.

On the other hand, AlpineJs can solve small problems and more specific behaviors in the frontend, substituting libraries like JQuery. It gives us the possibility to handle components with independent states, with different hierarchies that allow consistent architectures.

One last point in favor is the decoupling capacity that Livewire gives us; we can easily handle the injection of dependencies in the different components we built. With this, we can easily modify without entirely rewriting the code. Of course, I'm assuming a clean architecture in Laravel, oriented to a reusable service layer, but we'll talk about this and more in another post soon!


Livewire Shopping Cart Guide
We will venture into the implementation of a shopping cart, which involves a product catalog, addition and removal to cart functionalities, and a simple checkout. The main objective is to understand how Laravel Livewire works and the most important characteristics of it.‌‌