Backend y arquitectura
Un backend en Laravel para una tienda en Ionic: migraciones, seeders y controladores RESTful
7 min de lectura
Un backend en Laravel para una tienda en Ionic: migraciones, seeders y controladores RESTful
Mi TFM del máster fue una app de ecommerce multiplataforma: un cliente en Ionic/Angular para los compradores y un backend en Laravel que servía tanto los datos que consumía la app como un panel de administración para gestionar productos, categorías, clientes y pedidos. Obtuvo Matrícula de Honor, pero lo que quiero dejar por escrito aquí no es tanto el resultado como las decisiones del lado de Laravel que volvería a tomar igual, y un par que no.
Dividir el trabajo antes de escribir código
Lo primero útil que hice fue dividir el backlog en cuatro bloques en lugar de una sola lista larga:
- Cliente: todo lo que ve el comprador en la app de Ionic: buscador, carrito, favoritos, detalle de producto, perfil, historial de pedidos.
- Administración: el panel en Laravel: tablas con filtros, formularios para añadir y editar categorías y clientes, exportaciones.
- Conexión: el contrato entre ambos lados.
- Pruebas.
Al repasar ese backlog, los bloques de cliente y administración están llenos de tareas marcadas, mientras que «conexión» y «pruebas» se quedaron casi vacíos. Es la lección más honesta de este artículo: la integración entre cliente y servidor, y las pruebas que la protegen, merecen sus propias tareas desde el primer día. Si solo existen como un título, se acaban haciendo de forma implícita y tarde.
Migraciones: el orden también es diseño
Mientras montaba la base de datos escribí una nota en mayúsculas: el orden en que creas las tablas importa. Laravel ejecuta las migraciones por orden de nombre de fichero, y una tabla con una clave ajena solo se puede crear después de que exista la tabla a la que apunta. En una tienda esa cadena es larga: usuarios, categorías, productos, pedidos, líneas de pedido, comentarios, favoritos.
Hay dos formas de declarar una clave ajena, y acabé prefiriendo la corta:
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')
->constrained()
->cascadeOnDelete()
->cascadeOnUpdate();
$table->timestamps();
});
constrained() deduce la tabla referenciada a partir del nombre de la columna (user_id → users), lo que solo funciona si sigues las convenciones de nombres de Laravel. Esa es la otra regla que anoté pronto: nombres de tabla en minúscula y en plural, porque Eloquent asocia automáticamente el modelo Product con la tabla products. Pelearse con esa convención cuesta más de lo que ahorra.
El método down() merece el mismo cuidado: hay que eliminar las claves ajenas antes de borrar las columnas o tablas de las que dependen; si no, migrate:rollback falla a mitad de camino.
También pasé pronto el diseño del esquema a dbdiagram.io. Ver las relaciones dibujadas dejaba claro qué migración tenía que ir primero.
Una duda de modelado que dejé por escrito
Una de las dudas que anoté durante un sprint fue si la tabla orders necesitaba una columna price, ya que el total se puede calcular a partir de las líneas del pedido. La respuesta con la que me quedé: no es estrictamente necesaria, pero mantenerla es un compromiso razonable por eficiencia al listar muchos pedidos, siempre que se actualice cada vez que cambian las líneas. Es una decisión pequeña, pero tenerla por escrito me evitó volver a discutirla cada vez que tocaba la pantalla de pedidos.
Seeders: datos realistas desde la primera semana
Los seeders hicieron que el panel se pudiera probar antes de que existiera ningún dato real. El patrón es sencillo: un array de registros, un método privado que los inserta y una llamada desde run():
class DatabaseSeeder extends Seeder
{
private array $users = [
['name' => 'Demo Admin', 'email' => 'admin@example.com', 'password' => 'secret'],
['name' => 'Demo Customer', 'email' => 'customer@example.com', 'password' => 'secret'],
];
public function run(): void
{
$this->seedUsers();
$this->command->info('Users table seeded.');
}
private function seedUsers(): void
{
foreach ($this->users as $data) {
User::create([...$data, 'password' => bcrypt($data['password'])]);
}
}
}
Durante el desarrollo, php artisan migrate:refresh --seed se convirtió en mi botón de reinicio: reconstruye el esquema, recarga los datos y todas las pantallas vuelven a un estado conocido. Las contraseñas pasan por bcrypt() incluso en los datos de prueba; es una costumbre que conviene tener desde el principio.
¿Eloquent o Query Builder?
Laravel ofrece dos capas sobre la base de datos. El Query Builder es una forma fluida de escribir operaciones SQL; Eloquent es el ORM que va encima, donde cada fila vuelve como una instancia de un modelo. Para un panel de administración lleno de pantallas CRUD, Eloquent fue la opción natural por defecto:
$product = Product::findOrFail($id); // 404 en vez de comprobar null
$recent = Order::where('price', '>', 100)->latest()->take(10)->get();
$count = Customer::count();
$average = Order::avg('price');
findOrFail() eliminó toda una categoría de errores del tipo «intentar leer una propiedad de null» en los controladores. Las funciones de agregación (count, max, min, avg, sum) cubrieron las cifras de resumen del panel sin escribir SQL a mano. Reservé el Query Builder (DB::table(...)) para los pocos sitios en los que quería una operación simple sobre un conjunto de filas y no necesitaba modelos, como vaciar una tabla dentro de un seeder.
Controladores RESTful
Para cada recurso que necesitaban el panel y la app (productos, categorías, clientes, pedidos) usé controladores de recurso:
php artisan make:controller ProductController --resource
Route::resource('products', ProductController::class);
Un comando te da las siete acciones estándar (index, create, store, show, edit, update, destroy) y una sola línea registra todas sus rutas, que php artisan route:list te deja revisar de un vistazo. Para endpoints que consume un cliente móvil en lugar de formularios Blade, Route::apiResource() registra lo mismo sin las acciones create y edit, que solo tienen sentido en formularios HTML.
El valor no está en lo que te ahorras de teclear, sino en la previsibilidad. Todos los recursos tienen la misma forma, así que tanto la parte de Ionic como la de administración sabían siempre qué URL y qué verbo usar.
El panel de administración: donde se acaban los valores por defecto
La paginación integrada de Laravel fue lo primero que probé para las tablas del panel, y funciona, pero no incluye búsqueda en vivo ni botones de exportación. Pasé esas tablas a DataTables, que filtra en tiempo real en el cliente, y añadí exportaciones a PDF y Excel de productos, clientes y pedidos. DataTables tiene sus propios límites, y con un catálogo real me plantearía filtrar en el servidor; para un TFM era suficiente.
Imágenes de producto: storage, no la carpeta pública
Las imágenes de producto y de perfil pasan por la capa de almacenamiento de Laravel. Los ficheros que se guardan en el disco public viven en storage/app/public, y php artisan storage:link los expone bajo /storage. Usar $request->file('image')->store('products', 'public') además guarda el fichero con un nombre generado (un hash) en lugar del nombre con el que lo subió el usuario, lo que evita nombres de fichero llenos de comillas, HTML o caracteres raros.
El único obstáculo real fue el tamaño: las subidas por encima de los valores por defecto de PHP (upload_max_filesize = 2M, post_max_size = 8M) fallaban. Hay que subir ambos valores en php.ini, con post_max_size al menos tan grande como el límite de subida, y reiniciar el servidor. En su momento encontré el php.ini activo dejando un fichero con phpinfo() en public/, un atajo que hoy considero un antipatrón: php --ini te da la misma ruta desde la terminal, y una página con phpinfo() olvidada en una carpeta pública regala toda la configuración del servidor a quien la encuentre. También redimensioné las imágenes en el servidor para que la app no descargara fotos a tamaño completo para las miniaturas.
Qué mantendría y qué cambiaría
Mantendría las convenciones (tablas en minúscula y en plural, foreignId()->constrained(), controladores de recurso, seeders como botón de reinicio) porque me permitieron avanzar rápido sin pensar en la fontanería. Cambiaría dos cosas: dar al contrato entre cliente y servidor y a las pruebas sus propias tareas desde el primer sprint, y diseñar las tablas del panel con filtrado en el servidor desde el principio. Ninguna de las dos habría cambiado la nota; las dos habrían hecho que el código fuera más fácil de seguir ampliando.