En esta entrada vamos a aprender a implementar Datatables en Vuejs 2 con Vuetify utilizando Laravel como servidor.
No va a ser una entrada sencilla, se da por hecho tener conocimientos avanzados de Vuejs 2 y Laravel 5, cualquier duda recuerda que tienes muchos cursos disponibles y actualizados en la plataforma para poder entender esta entrada de forma completa.
La idea es imaginar que tenemos 2 proyectos distintos, un cliente con Vuejs 2 y un servidor con Laravel 5 y que necesitamos crear una tabla avanzada que nos permita filtrar, buscar, ordenar y paginar, pero todo ello en el servidor, no sólo en el cliente.
Pero no sólo eso, además de tener una tabla compleja, también vamos a añadir cierta lógica para poder generar y descargar archivos Excel con Vuejs 2 de la búsqueda actual de nuestra tabla,
El motivo de querer hacerlo en el servidor es muy sencillo, si utilizamos Datatables en el cliente y tenemos 2.000.000 de datos, esos 2.000.000 de datos tendrán que ser retornados en una única petición, algo inviable, en cambio, si lo hacemos en el servidor, únicamente retornaremos los que deseamos mostrar en cada página, algo que hará que nuestras tablas rindan mucho más.
Otro tema importante será ver cómo utilizar la función debounce de lodash dentro de los métodos de Vuejs para evitar hacer múltiples peticiones en el evento keyup cuando un usuario esté realizando búsquedas en nuestra aplicación, algo súper importante.
Servidor con Laravel 5
Lo primero que vamos a hacer es definir todo lo que necesitamos en nuestro servidor, es decir, un modelo User con un método Scope de Eloquent para tener los datos filtrados y ordenados de forma correcta y que esa información pueda ser reutilizada para el export de archivos Excel y para el listado de Datatables con Vuejs 2.
Así que procedemos a añadir el siguiente código, cada uno en su archivo correspondiente:
User.php
<?php
namespace App;
use Illuminate\Foundation\Auth\User as Authenticatable;
class User extends Authenticatable
{
protected $guarded = ['id'];
public function role () {
return $this->belongsTo(Role::class);
}
public function scopeFiltered ($query, $paginated = true) {
$search = request('search');
$sortBy = request('sortBy');
$order = request('descending') == 'true' ? 'desc' : 'asc';
$role = request('role');
$active = request('active');
$query->select(
'id', 'role_id', 'username', 'firstname', 'lastname', 'email', 'phone_number', 'active'
)
->with(
[
'role' => function ($query) {
$query->select('id', 'name');
}
]
);
if ($role != 'null') {
$query->whereHas('role', function ($query) use ($role) {
$query->where('name', $role);
});
}
if ($active != 'null') {
switch ($active) {
case 'Desactivado': {
$query->where('active', 0);
break;
}
case 'Activado': {
$query->where('active', 1);
break;
}
}
}
if ($search) {
$query->where('username', 'LIKE', "%$search%")->orWhere('firstname', 'LIKE', "%$search%")->orWhere('lastname', 'LIKE', "%$search%")->orWhere('email', 'LIKE', "%$search%");
}
switch ($sortBy) {
case 'username':
case 'firstname':
case 'lastname':
case 'email': {
$query->orderBy($sortBy, $order);
}
}
return $query;
}
}UserController.php
<?php
namespace App\Http\Controllers;
use App\Role;
use App\User;
class UserController extends Controller
{
public function filters () {
$statuses = [
0 => 'Desactivado',
1 => 'Activado'
];
$roles = Role::select('name')->whereNotNull('name')->groupBy('name')->orderBy('name')->get();
$rolesData = [];
if ($roles) {
foreach ($roles as $role) {
$rolesData[] = $role->name;
}
}
return response()->json(['statuses' => $statuses, 'roles' => $rolesData]);
}
public function paginated () {
$rowsPerPage = request('rowsPerPage');
$users = User::filtered();
return response()->json($users->paginate($rowsPerPage != 'undefined' ? $rowsPerPage : 10));
}
public function allFiltered () {
$users = User::filtered(false);
return response()->json($users->get());
}
}web.php
<?php
Route::group(['prefix' => 'users'], function () {
Route::get('/paginated', 'UserController@paginated');
Route::get('/filters', 'UserController@filters');
Route::get('/all-filtered', 'UserController@allFiltered');
});La verdad que todos los métodos anteriores son muy simples, modelo, controlador y rutas, cada uno hace algo concreto, a saber:
El modelo accede a los datos de la base de datos.
El controlador utiliza el modelo para retornar información.
Las rutas ejecutan los controladores para mostrar la respuesta al cliente.
Almacén de datos en el cliente con Vuex
Para que nuestro trabajo en el cliente con Vuejs 2 se vea simplificado vamos a utilizar Vuex, si no lo conoces, en la plataforma tienes varios cursos.
actions.js
import Vue from "vue";
import {
API_ERROR,
SET_USERS,
SET_FILTERS_FOR_USERS,
SET_LOADER_USERS,
SET_LOADER_DOWNLOADING_FILTERED_USERS
} from '@/store/mutation-types';
export async function getFiltersForDropdown ({commit}) {
try {
const {data} = await Vue.axios({
url: `${process.env.VUE_API_URL}/users/reports/filters`
});
commit(SET_FILTERS_FOR_USERS, data)
} catch (e) {
commit(API_ERROR, e.message, {root: true})
}
}
export async function getUsers ({state, commit}, {pagination, filters}) {
try {
commit(SET_LOADER_USERS, true);
const filterParams = Object.keys(filters).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(filters[k])}`).join('&');
const filterPagination = Object.keys(pagination).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(pagination[k])}`).join('&');
const {data} = await Vue.axios({
url: `${process.env.VUE_API_URL}/users/paginated/?${filterPagination}&${filterParams}`
});
commit(SET_USERS, data)
} catch (e) {
commit(API_ERROR, e.message, {root: true})
} finally {
commit(SET_LOADER_USERS, false);
}
}
export async function getAllFilteredUsers ({state, commit}, {filters}) {
try {
commit(SET_LOADER_DOWNLOADING_FILTERED_USERS, true);
const filterParams = Object.keys(filters).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(filters[k])}`).join('&');
const {data} = await Vue.axios({
url: `${process.env.VUE_API_URL}/users/all-filtered/?${filterParams}`
});
return data;
} catch (e) {
commit(API_ERROR, e.message, {root: true})
} finally {
commit(SET_LOADER_DOWNLOADING_FILTERED_USERS, false);
}
}index.js
import * as actions from './actions';
import mutations from './mutations';
import * as getters from './getters';
import state from './state';
export default {
namespaced: true,
mutations,
actions,
state,
getters,
};mutations.js
import {
SET_USERS,
SET_FILTERS_FOR_USERS,
SET_LOADER_USERS,
SET_USERS_PER_PAGE,
SET_USERS_TABLE_ORDER,
SET_LOADER_DOWNLOADING_FILTERED_USERS,
UNSET_USERS_INFO
} from "@/store/mutation-types";
import initialState from "@/store/User/state";
export default {
[SET_USERS](state, payload) {
state.users = payload.data
state.pagination.page = payload.current_page
state.pagination.rowsPerPage = parseInt(payload.per_page)
state.totalUsers = payload.total
},
[SET_FILTERS_FOR_USERS](state, data) {
state.statuses = data.statuses;
state.roles = data.roles;
},
[SET_LOADER_USERS](state, processing) {
state.processing = processing;
},
[SET_LOADER_DOWNLOADING_FILTERED_USERS](state, show) {
state.processingFilteredUsers.show = show;
},
[SET_USERS_PER_PAGE](state, rowsPerPage) {
state.pagination.rowsPerPage = rowsPerPage;
},
[SET_USERS_TABLE_ORDER](state, pagination) {
state.pagination.sortBy = pagination.sortBy;
state.pagination.descending = pagination.descending;
},
[UNSET_USERS_INFO] (state) {
Object.keys(initialState).forEach(key => { state[key] = initialState[key] })
}
};state.js
const initialState = () => ({
processing: false,
processingFilteredUsers: {show: false, message: 'Descargando usuarios para exportar'},
users: [],
pagination: {
page: 1,
rowsPerPage: 10,
descending: true
},
totalUsers: 0,
statuses: [],
roles: [],
})
export default initialState();Mixin ExportUsersToExcel
Ahora debemos crear un mixin haciendo uso del paquete XLSX para poder exportar el filtro actual de usuarios a un archivo .xlsx.
import XLSX from 'xlsx';
export default {
methods: {
exportUsersToExcel (rows) {
let formattedRows = [];
if (rows.length > 0) {
const self = this;
rows.forEach(function (user) {
formattedRows.push({
username: user.username,
firstname: user.firstname,
lastname: user.lastname,
email: user.email,
phone_number: user.phone_number,
active: user.active == 1 ? 'Activado' : 'Desactivado',
role: self.$_.has(user, 'role.name') ? user.role.name : '',
})
})
}
let data = XLSX.utils.json_to_sheet(
formattedRows,
{
header: ['username', 'firstname', 'lastname', 'email', 'phone_number', 'active', 'role']
}
)
data['A1'].v = 'Usuario';
data['B1'].v = 'Nombre';
data['C1'].v = 'Apellidos';
data['D1'].v = 'Correo electrónico';
data['E1'].v = 'Teléfono';
data['F1'].v = 'Activado';
data['G1'].v = 'Rol';
const workbook = XLSX.utils.book_new()
const filename = 'usuarios';
XLSX.utils.book_append_sheet(workbook, data, filename)
XLSX.writeFile(workbook, `${filename}.xlsx`)
}
}
}Vista para mostrar la tabla de usuarios
ProgressBarCircularComponent.vue
<template>
<v-dialog persistent v-model="processing" content content-class="centered-dialog">
<v-container ma-0 pa-0 fluid fill-height>
<v-layout column justify-center align-center>
<v-progress-circular indeterminate :size="70" :width="7" color="red"></v-progress-circular>
<h1 class="white--text" v-text="processing.message"></h1>
</v-layout>
</v-container>
</v-dialog>
</template>
<script>
export default {
name: 'ProgressBarCircularComponent',
props: ['processing']
}
</script>
<style lang="stylus">
.dialog.centered-dialog,
.v-dialog.centered-dialog
background transparent
box-shadow none
border-radius 6px
height: 100%;
width: 100%;
overflow: hidden;
color whitesmoke,
overflow-y hidden !important
</style>Users.vue
<template>
<v-container fluid grid-list-md text-xs-center pa-4 mt-1>
<progress-bar-circular-component v-if="processingFilteredUsers.show" :processing="processingFilteredUsers" />
<v-layout row wrap>
<v-flex x12 pl-2 pr-3 pb-0>
<v-layout row>
<v-flex>
<v-btn dark color="green darken-4" @click.native="downloadDataForExport" :disabled="!users.length">
Generar y descargar Excel <v-icon right dark>cloud_download</v-icon>
</v-btn>
<v-btn dark color="red" @click.native="resetFilters">
Limpiar filtros <v-icon right dark>clear_all</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-flex>
<v-flex x12 pl-2 pr-3 pb-0>
<v-layout row>
<v-flex xs2 md2 lg3>
<v-text-field
@input="filterSearch"
append-icon="search"
label="Buscar"
single-line
hide-details
v-model="search"
></v-text-field>
</v-flex>
<v-flex xs3 md3 lg3>
<v-select
v-model="active"
:items="statuses"
label="Activado/Desactivado"
@change="filterStatus"
></v-select>
</v-flex>
<v-flex xs3 md3 lg3>
<v-select
v-model="role"
:items="roles"
label="Rol"
@change="filterRole"
></v-select>
</v-flex>
</v-layout>
</v-flex>
<v-flex xs12>
<v-data-table
:items="users"
:headers="headers"
class="elevation-1"
:pagination.sync="pagination"
:total-items="totalUsers"
:rows-per-page-items="rowsPerPageItems"
:loading="processing"
must-sort
>
<template slot="items" slot-scope="props">
<tr>
<td class="text-xs-center">{{ props.item.firstname || 'N/A' }}</td>
<td class="text-xs-center">{{ props.item.lastname || 'N/A' }}</td>
<td class="text-xs-center">{{ props.item.username || 'N/A' }}</td>
<td class="text-xs-center">{{ props.item.email || 'N/A' }}</td>
<td class="text-xs-center">{{ props.item.phone_number || 'N/A' }}</td>
<td class="text-xs-center" v-if="props.item.active == 1">
<v-chip color="green" text-color="white">Activado</v-chip>
</td>
<td class="text-xs-center" v-else>
<v-chip color="red" text-color="white">Desactivado</v-chip>
</td>
<td class="text-xs-center">{{ $_.has(props.item, 'role.name') ? props.item.role.name : '' }}</td>
</tr>
</template>
</v-data-table>
</v-flex>
</v-layout>
</v-container>
</template>
<script>
import {mapActions, mapMutations, mapState} from "vuex";
import * as moment from 'moment';
import {SET_USERS_PER_PAGE, SET_USERS_TABLE_ORDER} from "@/store/mutation-types";
import ExportToExcelUsers from "@/mixins/ExportToExcelUsers";
import ProgressBarCircularComponent from "@/components/ProgressBarCircularComponent";
import {debounce} from 'lodash';
export default {
name: 'UsersList',
mixins: [ExportToExcelUsers],
components: {
ProgressBarCircularComponent
},
async mounted () {
await this.getFiltersForDropdown()
},
data () {
return {
active: '',
role: '',
filters: {
search: '',
active: null,
role: null,
},
search: '',
buttonSelected: 'all',
rowsPerPageItems: [10, 25, 50, 100, 200],
headers: [
{value: 'username', text: 'Usuario', align: 'center'},
{value: 'firstname', text: 'Nombre', align: 'center'},
{value: 'lastname', text: 'Apellidos', align: 'center'},
{value: 'email', text: 'Correo electrónico', align: 'center'},
{value: 'phone_number', text: 'Teléfono', align: 'center'},
{value: 'active', text: 'Activado', align: 'center'},
{value: 'role', sortable: false, text: 'Rol', align: 'center'},
],
}
},
computed: {
...mapState('users', ['users', 'totalUsers', 'roles', 'statuses', 'processing', 'processingFilteredUsers']),
pagination: {
get () {
return this.$store.state.users.pagination;
},
set (pagination) {
this.SET_USERS_TABLE_ORDER(pagination);
this.getUsers({pagination, filters: this.filters})
}
},
},
methods: {
...mapActions('users', {
getUsers: 'getUsers',
getFiltersForDropdown: 'getFiltersForDropdown',
getAllFilteredUsers: 'getAllFilteredUsers'
}),
...mapMutations('users', {
SET_USERS_PER_PAGE,
SET_USERS_TABLE_ORDER
}),
async downloadDataForExport () {
const data = await this.getAllFilteredUsers({filters: this.filters});
this.exportUsersToExcel(data);
},
async updateUsers () {
await this.getUsers({pagination: this.pagination, filters: this.filters})
},
filterSearch: debounce(function (val) {
this.filters.search = val;
if (val) {
this.updateUsers()
}
}, 2000),
filterStatus (val) {
this.filters.active = val;
this.updateUsers()
},
filterRole (val) {
this.filters.role = val;
this.updateUsers();
},
resetFilters () {
this.active = '';
this.role = '';
this.search = '';
this.filters = {
search: '',
active: null,
role: null,
};
this.SET_USERS_PER_PAGE();
this.updateUsers();
}
}
}
</script>Con este último archivo damos por finalizado este Tip and Trick, que al fin y al cabo lo único que se pretende es ofrecer una completa receta para añadir este tipo de funcionalidades a nuestros proyectos, espero que te haya sido de ayuda.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción