In this blog post we will implement a simple Datatable with Laravel and Livewire 3 library.
Displaying data in tabular format is a common UI component for dealing with and displaying data. There is no doubt that you have dealt before with something like jquery datatables or simply building a data grid by yourself.
With Laravel and Livewire 3 let’s build a simple datatable.
First create a laravel project:
composer create-project laravel/laravel livewire3_datatable --prefer-dist
Next for using Livewire, we need to install Livewire composer package:
composer require livewire/livewire
At the time of writing this will install Livewire 3.
To customize livewire configuration, publish livewire config file:
php artisan livewire:publish --config
This will create livewire.php config file in the config/ directory.
If you open livewire.php config file you will see many config items, among of these is the layout and inject_assets:
'layout' => 'components.layouts.app', 'inject_assets' => true,
The layout key specifies the default layout to use for the livewire component rendering. The inject_assets key specifies that livewire automatically inject the javascript and css assets.
If inject_assets is false then you have to manually add the css and javascript assets using @livewireStyles and @livewireScripts blade directives.
Let’s create new app.blade.php layout file in resources/views/layouts/ directory
resources/views/layouts/app.blade.php
<!DOCTYPE html> <html lang="{{ str_replace('_', '-', app()->getLocale()) }}"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>{{ $title ?? 'Livewire Datatable' }}</title> <!-- Fonts --> <link rel="preconnect" href="https://fonts.bunny.net"> <link href="https://fonts.bunny.net/css?family=figtree:400,600&display=swap" rel="stylesheet" /> </head> <body class="antialiased"> {{ $slot }} </body> </html>
Then update the layout key in livewire.php to use this layout:
'layout' => 'layouts.app',
The $slot variable will be used to substitute the component view into this slot.
Posts Model and Migration
For demonstration i will create a posts table and model, for this create a new migration and model:
php artisan make:model Post -m
This command create a Post model and migration together. Now open the created migration file and update the up() method like so:
database/migrations/YY_YY_YY_creates_posts_table.php
.... .... return new class extends Migration { public function up(): void { Schema::create('posts', function (Blueprint $table) { $table->id(); $table->string('title'); $table->string('author'); $table->text('description'); $table->timestamps(); }); } ... ... }
I added other columns to the migration file which are title, author and description. Next Open .env and update the DB settings according to your server and then migrate:
php artisan migrate
This will ask you to create the database if not exists.
Next open the Post model and add the $fillable property like so:
app/Models/Post.php
<?php namespace App\Models; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; class Post extends Model { use HasFactory; protected $fillable = ["title", "author", "description"]; }
Data Seeding
We need to fill the posts table with some random data to work with. For this let’s create a factory and seeder.
Create new factory class:
php artisan make:factory PostFactory
Then open the factory located in database/factories
database/factories/PostFactory.php
<?php namespace Database\Factories; use Illuminate\Database\Eloquent\Factories\Factory; class PostFactory extends Factory { /** * Define the model's default state. * * @return array<string, mixed> */ public function definition(): array { return [ 'title' => fake()->text(100), 'author' => fake()->name(), 'description' => fake()->paragraph() ]; } }
Inside the factory definition() method we return an array of the model columns and using faker to generate random data.
Create a seeder class that use this factory:
php artisan make:seeder PostSeeder
Open the database/seeders/PostSeeder.php
<?php namespace Database\Seeders; use App\Models\Post; use Illuminate\Database\Seeder; class PostSeeder extends Seeder { /** * Run the database seeds. */ public function run(): void { Post::factory() ->count(200) ->create(); } }
In the PostSeeder class i invoked the run() method which calls the Post::factory to generate random data.
Now run the created seeder:
php artisan db:seed --class=PostSeeder
This will run the seeder and factory, and you can check your table to see if it’s populated with the data.
Creating Posts Component
The next step is to create livewire component for the posts using make:livewire artisan command:
php artisan make:livewire Posts\\CreatePost
This will create a new component class and view. Component classes typically will be inside of app/Livewire/. I specified that the component will be nested in the Posts/ directory. The view be in resources/views/livewire/posts/list-posts.blade.php
Let’s add a route for this component:
routes/web.php
Route::get('/', \App\Livewire\Posts\ListPosts::class);
In this case i added the component as a full page component.
Updating the List Posts Component
app/Livewire/Posts/ListPosts.php
<?php namespace App\Livewire\Posts; use App\Models\Post; use Livewire\Component; class ListPosts extends Component { public function render() { return view('livewire.posts.list-posts', [ 'posts' => Post::paginate(20) ]); } }
resources/views/livewire/posts/list-posts.blade.php
<div> <h3>All Posts</h3> <table> <thead> <tr> <th>#</th> <th>Title</th> <th>Author</th> <th>Description</th> </tr> </thead> <tbody> @foreach($posts as $post) <tr> <td>{{ $post->id }}</td> <td>{{ mb_substr($post->title, 0, 50, 'utf-8') }}...</td> <td>{{ $post->author }}</td> <td>{{ mb_substr($post->description, 0, 100, 'utf-8') }}...</td> </tr> @endforeach </tbody> </table> {!! $posts->links() !!} </div>
In the component class render() method, i passed the posts to the view. Next in the list-posts.blade.php view i displayed the posts using an @foreach laravel directive in a table.
The most interesting point is the pagination as we can easily utilize laravel pagination and works the same way as laravel normal blade views.
However when clicking on any pagination links it does a full page reload, while this is not supposed to happen in livewire component. To resolve this, you have to use the Livewire\WithPagination trait like so:
class ListPosts extends Component { use WithPagination; .... .... .... }
Now when clicking on any page number, the pagination works perfectly without doing full page reload. In addition to that the url updated with appending ?page parameter to persist the current page.
Data Filtering
In addition to that let’s add some filtering to the posts datatable.
First update the ListPosts component class like so:
app/Livewire/Posts/ListPosts.php
<?php namespace App\Livewire\Posts; use App\Models\Post; use Livewire\Component; use Livewire\WithPagination; class ListPosts extends Component { use WithPagination; public $postId = ''; public $title = ''; public $author = ''; public $description = ''; public function render() { $data = Post::whereRaw("1=1"); $this->filterData($data); $posts = $data->paginate(20); return view('livewire.posts.list-posts', [ 'posts' => $posts ]); } private function filterData($query) { if($this->postId) { $this->resetPage(); $query->where("id", '=', $this->postId); } if($this->title) { $this->resetPage(); $query->where("title", 'LIKE', '%'.$this->title.'%'); } if($this->author) { $this->resetPage(); $query->where("author", 'LIKE', '%'.$this->author.'%'); } if($this->description) { $this->resetPage(); $query->where("description", 'LIKE', '%'.$this->description.'%'); } } }
In this code i declared some properties like $postId, $title, etc. We will use these properties to bind it using wire:model.
I added an utility method filterData(). All it does this method is to apply eloquent filtering to the Posts model using particular criteria for the $postId, $title, $author etc.
Next in the list-posts.blade.php component view:
<div> <h3>All Posts</h3> <div wire:loading.delay.long> Loading.... </div> <table> <thead> <tr> <th> <input type="text" placeholder="post id" wire:model.blur="postId" /> </th> <th> <input type="text" placeholder="title" wire:model.blur="title" /> </th> <th> <input type="text" placeholder="author" wire:model.blur="author" /> </th> <th> <input type="text" placeholder="description" wire:model.blur="description" /> </th> </tr> <tr> <th>#</th> <th>Title</th> <th>Author</th> <th>Description</th> </tr> </thead> <tbody> @foreach($posts as $post) <tr> <td>{{ $post->id }}</td> <td>{{ mb_substr($post->title, 0, 50, 'utf-8') }}...</td> <td>{{ $post->author }}</td> <td>{{ mb_substr($post->description, 0, 100, 'utf-8') }}...</td> </tr> @endforeach </tbody> </table> {!! $posts->links() !!} </div>
To display loading indicator i added a div with wire:loading directive. In the table i added a row at the top with four inputs to filter by PostId, title, author, description respectively. Then i bind them using wire:model:
<input type="text" placeholder="post id" wire:model.blur="postId" />
The .blur modifier allows for live updating while typing into the input and clicking away so that the filtering works without clicking a submit button.
Now if you try to type on any of the input it will do live filtering and pagination.
If you notice above in the component class the call to $this->resetPage(). This method provided by livewire Pagination and this is important when using pagination and filtering so that paginator reset the page back to 1.
Sorting Into Datatable
We can also make the sorting functionality into the Datatable like the filtering. At first i created an utility component that acts as the table column head.
php artisan make:livewire TableColumn
Open the component class app/Livewire/TableColumn.php
<?php namespace App\Livewire; use Livewire\Component; class TableColumn extends Component { public $label; public $column; public $sortBy; public $sortDir; public function render() { return view('livewire.table-column'); } }
This component receive some props like label, column, the current $sortBy and $sortDir and renders a column and two arrows beside to allow sorting ASC or DESC.
resources/views/livewire/table-column.blade.php
<span title="Sort by {{$column}} {{$sortDir=='ASC'?'DESC':'ASC'}}" style="cursor: pointer" wire:click="$parent.sort('{{ $column }}', '{{ $sortDir=='ASC'?'DESC':'ASC' }}')"> <span>{{$label}}</span> @if($sortBy == $column) <span> @if(strtolower($sortDir) === 'asc') <span>↑</span> @elseif(strtolower($sortDir) === 'desc') <span>↓</span> @endif </span> @endif </span>
In this view i am displaying the column label and the arrows. When clicking on any column we trigger an action declared on the parent component using $parent.sort().$parent is one of the magic actions of livewire and allow us to call actions on parent component.
If the current column equal to the $sortBy variable then it displays the arrows for this column. Then i am checking for the $sortDir equal to asc or desc then we display the appropriate arrow.
Update ListPosts component like so:
In app/Livewire/Posts/ListPosts.php make these updates:
<?php ..... ..... ..... class ListPosts extends Component { use WithPagination; ..... ..... ..... public $sortBy = 'id'; public $sortDir = 'DESC'; public function sort($column, $direction) { $this->sortBy = $column; $this->sortDir = $direction; } public function render() { $data = Post::whereRaw("1=1"); $this->filterData($data); $data->orderBy($this->sortBy, $this->sortDir); $posts = $data->paginate(20); return view('livewire.posts.list-posts', [ 'posts' => $posts ]); } .... .... .... }
In this code i added two properties $sortBy and $sortDir with initial values of id and DESC respectively. I added method sort() which receives the new $sortBy and $sortDir and assign them to the class properties. I updated the render() method to allow sorting using eloquent $data->orderBy() method.
Make this update in resources/views/livewire/list-posts.blade.php
<div> <h3>All Posts</h3> <div wire:loading.delay.long> Loading.... </div> <table> <thead> <tr> <th> <input type="text" placeholder="post id" wire:model.blur="postId" /> </th> <th> <input type="text" placeholder="title" wire:model.blur="title" /> </th> <th> <input type="text" placeholder="author" wire:model.blur="author" /> </th> <th> <input type="text" placeholder="description" wire:model.blur="description" /> </th> </tr> <tr> <th> <livewire:table-column label="#" column="id" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_id'}}" /> </th> <th> <livewire:table-column label="Title" column="title" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_title'}}" /> </th> <th> <livewire:table-column label="Author" column="author" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_author'}}" /> </th> <th> <livewire:table-column label="Description" column="description" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_description'}}" /> </th> </tr> </thead> <tbody> @foreach($posts as $post) <tr> <td>{{ $post->id }}</td> <td>{{ mb_substr($post->title, 0, 50, 'utf-8') }}...</td> <td>{{ $post->author }}</td> <td>{{ mb_substr($post->description, 0, 100, 'utf-8') }}...</td> </tr> @endforeach </tbody> </table> {!! $posts->links() !!} </div>
The only change here is replacing the column headers with the new component table-column as in the title:
<livewire:table-column label="Title" column="title" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_title'}}" />
Passing in the props label, column, sortBy and sortDir. The key prop is essential to keep updating the child component whenever any prop changes.
Now if you try this you will see that the sorting functionality is working.
Styling the Datatable With Tailwindcss
Let’s add some styling to the Datatable using tailwindcss. I suppose you are using vite to bundle the assets.
First install the tailwindcss into the project
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Replace the code inside the tailwind.config.js file with this:
/** @type {import('tailwindcss').Config} */ export default { content: [ './resources/**/*.blade.php', ], theme: { extend: {}, }, plugins: [], }
Open resources/css/app.css and add those lines:
@tailwind base; @tailwind components; @tailwind utilities;
Update the main layout app.blade.php add @vite directive before the closing </head>:
@vite('resources/css/app.css')
In the terminal run:
npm run dev
If you refresh the page now you will see table styling looks different meaning tailwindcss already loaded.
Let’s apply some tailwind classes to the component views.
So open list-posts.blade.php and update it like so:
<div> <h3 class="px-3 py-6 text-2xl">All Posts</h3> <div wire:loading.delay.long> Loading.... </div> <table class="w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400"> <thead class="text-xs text-gray-700 uppercase bg-gray-200 dark:bg-gray-700 dark:text-gray-400"> <tr> <th class="px-6 py-3"> <input type="text" class="px-6 py-3 text-sm" placeholder="post id" wire:model.blur="postId" /> </th> <th class="px-6 py-3"> <input type="text" class="px-6 py-3" placeholder="title" wire:model.blur="title" /> </th> <th class="px-6 py-3"> <input type="text" class="px-6 py-3" placeholder="author" wire:model.blur="author" /> </th> <th class="px-6 py-3"> <input type="text" class="px-6 py-3" placeholder="description" wire:model.blur="description" /> </th> </tr> <tr> <th class="px-6 py-3"> <livewire:table-column label="#" column="id" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_id'}}" /> </th> <th class="px-6 py-3"> <livewire:table-column label="Title" column="title" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_title'}}" /> </th> <th class="px-6 py-3"> <livewire:table-column label="Author" column="author" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_author'}}" /> </th> <th class="px-6 py-3"> <livewire:table-column label="Description" column="description" :sortBy="$sortBy" :sortDir="$sortDir" key="{{now().'_description'}}" /> </th> </tr> </thead> <tbody> @foreach($posts as $post) <tr class="bg-white border-b dark:bg-gray-800 dark:border-gray-700"> <td class="px-6 py-4">{{ $post->id }}</td> <td class="px-6 py-4">{{ mb_substr($post->title, 0, 50, 'utf-8') }}...</td> <td class="px-6 py-4">{{ $post->author }}</td> <td class="px-6 py-4">{{ mb_substr($post->description, 0, 100, 'utf-8') }}...</td> </tr> @endforeach </tbody> </table> {!! $posts->links() !!} </div>
Open table-column.blade.php and update like so:
<span title="Sort by {{$column}} {{$sortDir=='ASC'?'DESC':'ASC'}}" style="cursor: pointer" wire:click="$parent.sort('{{ $column }}', '{{ $sortDir=='ASC'?'DESC':'ASC' }}')" > <span class="{{ $sortBy == $column?'underline':'' }}">{{$label}}</span> @if($sortBy == $column) <span class="text-xl"> @if(strtolower($sortDir) === 'asc') <span>↑</span> @elseif(strtolower($sortDir) === 'desc') <span>↓</span> @endif </span> @endif </span>