Skip to content

🎯 Meta: construir tu primera API REST completa con base de datos, validación, tests y buenas prácticas. Laravel es el framework más "amable" para empezar: te da todo hecho y ves resultados rápido.

Versiones: PHP 8.5.8 · Laravel 13.8 (requiere PHP 8.3+).


3.1 · ¿Por qué empezar por Laravel?

  • Es el framework PHP más popular del mundo → muchísimo trabajo y comunidad.
  • "Baterías incluidas": ORM, migraciones, validación, autenticación, colas, tests… todo viene de fábrica. No tienes que decidir 20 librerías antes de escribir una línea.
  • Su filosofía (convención sobre configuración) te enseña buenas prácticas por defecto.
  • El patrón MVC que aprenderás aquí lo reconocerás luego en NestJS, Django, etc.

🧠 MVC en una frase: Modelo (los datos y su lógica) + Vista (lo que se muestra) + Controlador (recibe la petición, orquesta y responde). En una API, la "vista" es el JSON.


3.2 · Instalación

Necesitas PHP 8.5 y Composer (el gestor de paquetes de PHP).

bash
# Comprobar versiones
php --version        # debe decir 8.3 o superior
composer --version

# Crear un proyecto nuevo con el instalador de Laravel
composer global require laravel/installer
laravel new tienda-api

# O directamente con composer:
composer create-project laravel/laravel tienda-api

cd tienda-api
php artisan serve       # arranca en http://localhost:8000

artisan es la navaja suiza de Laravel (su CLI). La usarás constantemente.

💡 Tip: para instalar PHP en Windows sin dramas, usa Laravel Herd (gratis): trae PHP, Composer y un servidor listos. En Mac/Linux, Herd o brew/apt. Aún mejor: usa Docker (cap. 12) con Laravel Sail y te olvidas de instalar PHP en tu máquina.


3.3 · Estructura del proyecto

tienda-api/
├── app/
│   ├── Models/           ← Modelos (una clase por tabla)
│   ├── Http/
│   │   ├── Controllers/  ← Controladores (lógica de cada endpoint)
│   │   ├── Requests/     ← Validación de datos de entrada
│   │   └── Resources/    ← Cómo se transforma el modelo a JSON
│   └── Providers/
├── database/
│   ├── migrations/       ← Cambios en el esquema de la BD (versionados)
│   ├── factories/        ← Generadores de datos falsos para tests
│   └── seeders/          ← Datos iniciales
├── routes/
│   ├── api.php           ← Rutas de la API  ← AQUÍ defines los endpoints
│   └── web.php
├── tests/
│   ├── Feature/          ← Tests de integración (peticiones completas)
│   └── Unit/             ← Tests unitarios
├── .env                  ← Configuración (NO subir a git)
└── composer.json

3.4 · Configurar la base de datos

Edita el .env para apuntar a tu PostgreSQL (cap. 01):

bash
DB_CONNECTION=pgsql
DB_HOST=127.0.0.1
DB_PORT=5432
DB_DATABASE=tienda
DB_USERNAME=postgres
DB_PASSWORD=secreto

3.5 · Migraciones — El esquema versionado

En vez de crear tablas a mano con SQL, Laravel usa migraciones: archivos PHP que describen el esquema. Ventaja: quedan en git, cualquiera reconstruye la BD con un comando.

bash
php artisan make:migration create_productos_table

Edita el archivo generado en database/migrations/:

php
<?php

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

return new class extends Migration {
    public function up(): void
    {
        Schema::create('productos', function (Blueprint $table) {
            $table->id();                              // BIGINT autoincremental
            $table->string('nombre');                  // VARCHAR
            $table->text('descripcion')->nullable();   // TEXT opcional
            $table->decimal('precio', 10, 2);          // NUMERIC(10,2) para dinero
            $table->unsignedInteger('stock')->default(0);
            $table->boolean('activo')->default(true);
            $table->timestamps();                      // created_at y updated_at (¡gratis!)
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('productos');   // cómo revertir
    }
};

Aplica las migraciones:

bash
php artisan migrate

💡 Tip: php artisan migrate:fresh --seed borra todo y recrea la BD desde cero con datos de prueba. Perfecto en desarrollo cuando quieres "empezar limpio".


3.6 · El Modelo (Eloquent ORM)

Un modelo representa una tabla. Eloquent es el ORM: traduces filas ↔ objetos PHP sin escribir SQL.

bash
php artisan make:model Producto
php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class Producto extends Model
{
    // Qué campos se pueden asignar en masa (seguridad):
    protected $fillable = ['nombre', 'descripcion', 'precio', 'stock', 'activo'];

    // Convertir tipos automáticamente:
    protected $casts = [
        'precio' => 'decimal:2',
        'activo' => 'boolean',
    ];
}

Con esto ya puedes hacer (sin escribir SQL):

php
Producto::all();                          // SELECT * FROM productos
Producto::find(1);                        // ... WHERE id = 1
Producto::where('activo', true)->get();   // ... WHERE activo = true
Producto::create(['nombre' => 'Teclado', 'precio' => 99.90]);   // INSERT
$p = Producto::find(1); $p->stock = 5; $p->save();              // UPDATE
Producto::find(1)->delete();              // DELETE

🔗 Todo esto por debajo genera el SQL del capítulo 01. El ORM te ahorra escribirlo, pero debes saber qué SQL genera — por eso viste SQL primero. Un ORM mal usado genera consultas horribles (el problema "N+1", que veremos).


3.7 · Rutas y Controlador (la API REST)

Genera un controlador de recurso (ya trae los 7 métodos CRUD):

bash
php artisan make:controller ProductoController --api --model=Producto

Registra las rutas en routes/api.phpuna línea crea las 5 rutas REST:

php
use App\Http\Controllers\ProductoController;

Route::apiResource('productos', ProductoController::class);

Esto genera automáticamente:

GET    /api/productos          → index()   listar
POST   /api/productos          → store()   crear
GET    /api/productos/{id}     → show()    ver uno
PUT    /api/productos/{id}     → update()  editar
DELETE /api/productos/{id}     → destroy() borrar

El controlador:

php
<?php

namespace App\Http\Controllers;

use App\Models\Producto;
use App\Http\Requests\ProductoRequest;
use App\Http\Resources\ProductoResource;
use Illuminate\Http\Request;

class ProductoController extends Controller
{
    // GET /api/productos
    public function index(Request $request)
    {
        $productos = Producto::query()
            ->when($request->activo, fn($q) => $q->where('activo', true))
            ->orderByDesc('created_at')
            ->paginate(15);          // paginación automática

        return ProductoResource::collection($productos);
    }

    // POST /api/productos
    public function store(ProductoRequest $request)
    {
        $producto = Producto::create($request->validated());
        return new ProductoResource($producto);   // Laravel responde 201 solo
    }

    // GET /api/productos/{producto}
    public function show(Producto $producto)      // ← inyección automática por id
    {
        return new ProductoResource($producto);
    }

    // PUT /api/productos/{producto}
    public function update(ProductoRequest $request, Producto $producto)
    {
        $producto->update($request->validated());
        return new ProductoResource($producto);
    }

    // DELETE /api/productos/{producto}
    public function destroy(Producto $producto)
    {
        $producto->delete();
        return response()->noContent();           // 204
    }
}

🧠 Route Model Binding: fíjate en show(Producto $producto). Laravel ve el {producto} de la URL, busca automáticamente ese id en la BD y te lo inyecta ya cargado. Si no existe, devuelve 404 solo. Menos código, menos bugs.


3.8 · Validación (Form Requests)

Nunca confíes en los datos que llegan. Genera una clase de validación:

bash
php artisan make:request ProductoRequest
php
<?php

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true;   // aquí irían permisos
    }

    public function rules(): array
    {
        return [
            'nombre'      => ['required', 'string', 'max:255'],
            'descripcion' => ['nullable', 'string'],
            'precio'      => ['required', 'numeric', 'min:0'],
            'stock'       => ['integer', 'min:0'],
            'activo'      => ['boolean'],
        ];
    }

    // Mensajes en español (opcional):
    public function messages(): array
    {
        return [
            'nombre.required' => 'El nombre es obligatorio.',
            'precio.min'      => 'El precio no puede ser negativo.',
        ];
    }
}

Si la validación falla, Laravel devuelve automáticamente 422 con los errores en JSON. No escribes ni un if.


3.9 · Resources — Controlar el JSON de salida

Un Resource decide exactamente qué campos y cómo se ven en la respuesta (nunca expongas la tabla cruda: podrías filtrar contraseñas o campos internos).

bash
php artisan make:resource ProductoResource
php
<?php

namespace App\Http\Resources;

use Illuminate\Http\Resources\Json\JsonResource;

class ProductoResource extends JsonResource
{
    public function toArray($request): array
    {
        return [
            'id'          => $this->id,
            'nombre'      => $this->nombre,
            'precio'      => (float) $this->precio,
            'disponible'  => $this->stock > 0,       // campo calculado
            'creado'      => $this->created_at->toIso8601String(),
        ];
    }
}

3.10 · Relaciones (Eloquent)

Aquí brilla el ORM. Define la relación una vez y navegas los datos como objetos:

php
// En Producto.php
public function categoria()
{
    return $this->belongsTo(Categoria::class);
}

// En Categoria.php
public function productos()
{
    return $this->hasMany(Producto::class);
}
php
$producto->categoria->nombre;          // el nombre de su categoría
$categoria->productos;                 // todos sus productos

// ⚠️ Evita el problema N+1: carga las relaciones de golpe con "with"
Producto::with('categoria')->get();    // 2 consultas, no N+1

⚠️ El problema N+1 (memorízalo): si listas 100 productos y para cada uno accedes a su categoría, haces 1 + 100 = 101 consultas. Con with('categoria') haces solo 2. Es la causa nº1 de APIs lentas hechas con ORM. Instala Laravel Telescope o mira el debugbar para detectarlo.


3.11 · Tests (PHPUnit / Pest)

Laravel trae testing de fábrica. Desde Laravel 11+ el estilo recomendado es Pest (más legible). Un test de feature prueba la API de punta a punta contra una BD de prueba:

php
<?php
// tests/Feature/ProductoTest.php

use App\Models\Producto;

it('lista productos', function () {
    Producto::factory()->count(3)->create();

    $response = $this->getJson('/api/productos');

    $response->assertStatus(200)
             ->assertJsonCount(3, 'data');
});

it('crea un producto válido', function () {
    $datos = ['nombre' => 'Mouse', 'precio' => 49.90, 'stock' => 10];

    $response = $this->postJson('/api/productos', $datos);

    $response->assertStatus(201)
             ->assertJsonPath('data.nombre', 'Mouse');

    // Comprobar que quedó en la BD:
    $this->assertDatabaseHas('productos', ['nombre' => 'Mouse']);
});

it('rechaza un producto sin nombre', function () {
    $response = $this->postJson('/api/productos', ['precio' => 10]);

    $response->assertStatus(422)
             ->assertJsonValidationErrors('nombre');
});

Necesitas una factory para generar datos de prueba:

php
<?php
// database/factories/ProductoFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;

class ProductoFactory extends Factory
{
    public function definition(): array
    {
        return [
            'nombre' => fake()->words(2, true),
            'precio' => fake()->randomFloat(2, 5, 500),
            'stock'  => fake()->numberBetween(0, 100),
        ];
    }
}

Ejecuta los tests:

bash
php artisan test
# o con más detalle:
php artisan test --coverage

💡 Tip: configura una BD de test separada (o usa SQLite en memoria) en phpunit.xml con RefreshDatabase. Cada test corre con una BD limpia y no ensucia tus datos reales. Es el hábito que te hace un desarrollador serio.


3.12 · Autenticación de API (Sanctum)

Para proteger endpoints con tokens, Laravel usa Sanctum:

bash
php artisan install:api          # instala Sanctum y configura todo
php
// Proteger rutas con middleware auth:
Route::middleware('auth:sanctum')->group(function () {
    Route::apiResource('productos', ProductoController::class);
    Route::get('/perfil', fn(Request $r) => $r->user());
});

// Endpoint de login que devuelve el token:
Route::post('/login', function (Request $request) {
    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        return response()->json(['message' => 'Credenciales inválidas'], 401);
    }

    return ['token' => $user->createToken('api')->plainTextToken];
});

El cliente manda luego Authorization: Bearer <token> en cada petición (cap. 00).


3.13 · Buenas prácticas Laravel (tips de oro)

  1. Controladores delgados, modelos/servicios gordos. Si un controlador tiene más de ~15 líneas por método, mueve la lógica a una clase Service o Action.
  2. Usa Form Requests para validar, nunca valides en el controlador.
  3. Nunca Producto::all() en tablas grandes → usa paginate().
  4. Siempre with() para relaciones que vas a usar (evita N+1).
  5. $fillable explícito en cada modelo (evita mass assignment peligroso).
  6. Migraciones para todo cambio de esquema, nunca toques la BD a mano en producción.
  7. .env fuera de git. Sube .env.example.
  8. Colas para tareas lentas (enviar email, procesar imagen): dispatch(new EnviarCorreo()).

🔗 Estos principios (controladores delgados, separar responsabilidades) son SOLID aplicado. Lo verás formalmente en el capítulo 10.


3.14 · Colas y Jobs — trabajo pesado fuera de la petición

Enviar un email, procesar una imagen o llamar a una API externa no debe bloquear la respuesta al cliente. Un Job encapsula esa tarea y se ejecuta en segundo plano, procesado por un worker independiente (conecta con el patrón de colas del apéndice B, aquí con la implementación nativa de Laravel):

bash
php artisan make:job EnviarConfirmacionPedido
php
<?php
// app/Jobs/EnviarConfirmacionPedido.php
namespace App\Jobs;

use App\Models\Pedido;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;

class EnviarConfirmacionPedido implements ShouldQueue
{
    use Queueable, InteractsWithQueue, SerializesModels;

    public int $tries = 3;                 // reintentos antes de darse por vencido
    public array $backoff = [10, 60, 300];  // espera creciente entre reintentos (segundos)

    public function __construct(public Pedido $pedido) {}   // solo el ID se serializa, no todo el modelo

    public function handle(): void
    {
        Mail::to($this->pedido->cliente->email)->send(new ConfirmacionPedido($this->pedido));
    }

    public function failed(\Throwable $e): void
    {
        // se llama tras agotar los reintentos — loguea, avisa a soporte, nunca lo dejes en silencio
        Log::error("Confirmación de pedido {$this->pedido->id} falló definitivamente", ['error' => $e->getMessage()]);
    }
}
php
// Encolar el job — no bloquea la petición HTTP:
EnviarConfirmacionPedido::dispatch($pedido);

// Encadenar jobs que deben correr en orden, o en lote paralelo:
Bus::chain([new ReservarStock($pedido), new EnviarConfirmacionPedido($pedido)])->dispatch();
bash
# Configura el driver en .env (redis en producción, nunca "sync"):
QUEUE_CONNECTION=redis

# Arranca un worker (en producción, bajo supervisor/systemd, nunca a mano):
php artisan queue:work --tries=3 --backoff=10

⚠️ Los jobs de Laravel son at-least-once, no exactly-once. Si el worker muere justo después de cobrar una tarjeta pero antes de marcar el job como terminado, Laravel lo reintenta desde cero — y volvería a cobrar. Haz tus jobs idempotentes: comprueba primero si el efecto ya ocurrió (if ($pedido->confirmado) return;) antes de ejecutarlo. Es el mismo principio de idempotencia de webhooks (apéndice D.5) y colas (apéndice B).

💡 ShouldBeUnique evita duplicados en la propia cola: si el mismo pedido puede encolar el job dos veces (doble clic, reintento del cliente), implementa ShouldBeUnique para que Laravel descarte el segundo mientras el primero sigue en cola. Y despacha jobs que dependen de una transacción con ->afterCommit() — si no, el worker puede leer el registro antes de que el commit se confirme y encontrar datos a medias.

Monitorización: instala Laravel Horizon (composer require laravel/horizon) si usas Redis — te da un dashboard de jobs en curso, fallidos y reintentos, con métricas de throughput. Conecta con el capítulo de observabilidad (apéndice F): sin verlo, un worker caído pasa desapercibido hasta que el cliente se queja de que nunca le llegó el email.


3.15 · Autorización — Policies y Gates

La autenticación (Sanctum, 3.12) responde "¿quién eres?"; la autorización responde "¿puedes hacer esto?". Mezclar esa lógica dentro de los controladores (if ($user->id !== $pedido->user_id) repetido en cada acción) es la forma más rápida de olvidar una comprobación. Una Policy centraliza las reglas por modelo:

bash
php artisan make:policy PedidoPolicy --model=Pedido
php
<?php
// app/Policies/PedidoPolicy.php
namespace App\Policies;

use App\Models\{Pedido, User};

class PedidoPolicy
{
    public function ver(User $user, Pedido $pedido): bool
    {
        return $user->id === $pedido->user_id || $user->esAdmin();
    }

    public function cancelar(User $user, Pedido $pedido): bool
    {
        return $user->id === $pedido->user_id && $pedido->estado === 'pendiente';
    }
}
php
// En el controlador — una línea, la excepción 403 la lanza Laravel solo:
public function cancelar(Pedido $pedido)
{
    $this->authorize('cancelar', $pedido);   // 403 automático si falla
    $pedido->update(['estado' => 'cancelado']);
    return new PedidoResource($pedido);
}
php
// En Blade/tests, sin lanzar excepción:
if ($user->can('cancelar', $pedido)) { /* ... */ }

🧠 Policy vs Gate: una Policy agrupa reglas de un modelo concreto (Pedido, Producto); un Gate (Gate::define('acceder-admin', fn($user) => $user->esAdmin())) es para permisos que no giran alrededor de un modelo. El 90% de tu autorización en una API CRUD serán Policies.


3.16 · Laravel Octane — cuando PHP tradicional no basta

Por defecto, PHP arranca la aplicación desde cero en cada petición (bootea el framework, carga rutas, providers…) — simple y aislado, pero con overhead. Octane mantiene la aplicación en memoria entre peticiones (sobre Swoole o RoadRunner), eliminando ese arranque repetido:

bash
composer require laravel/octane
php artisan octane:install --server=swoole

php artisan octane:start --workers=4 --max-requests=500

⚠️ Octane exige disciplina stateless. Como la app vive en memoria entre peticiones, un estado guardado accidentalmente en una propiedad estática o un singleton se filtra entre usuarios distintos — el bug más traicionero de migrar a Octane. --max-requests=500 mitiga fugas de memoria acumuladas reiniciando el worker periódicamente, pero no sustituye código stateless.

💡 ¿Cuándo migrar? Solo cuando el overhead de arranque de PHP sea el cuello de botella real (APIs de alto tráfico, muchas peticiones pequeñas). Para el 90% de proyectos, PHP-FPM tradicional (cap. 13/14) es más simple de operar y suficientemente rápido. Mide antes de optimizar.


✅ Ejercicio del capítulo

Implementa la API del blog que diseñaste en el capítulo 00:

1. Migraciones: usuarios (ya viene), articulos, comentarios.
2. Modelos con relaciones: Articulo hasMany Comentarios, belongsTo User.
3. apiResource para articulos y comentarios anidados
   (/articulos/{id}/comentarios).
4. Form Requests con validación.
5. Resources para el JSON.
6. Proteger crear/editar/borrar con auth:sanctum.
7. Tests: al menos 5 (listar, crear, validación 422, 404, borrar).
8. Evita el N+1 al listar artículos con su autor y nº de comentarios.
9. Añade un Job `EnviarNotificacionComentario` que se encole al crear un comentario
   (no bloquees la respuesta HTTP) y hazlo idempotente.
10. Crea una `ComentarioPolicy`: solo el autor del comentario (o un admin) puede borrarlo.

Cuando tu php artisan test esté todo en verde, has construido tu primera API profesional. 🎉

💡 Pistas de la solución (abre solo si te atascas)
  • El N+1 del punto 8 aparece al hacer $articulo->comentarios->count() dentro de un bucle sobre artículos: cada llamada dispara una query nueva. Usa withCount('comentarios') en la consulta principal — Laravel lo resuelve en una sola query con subconsulta.
  • Los comentarios anidados van con Route::apiResource('articulos.comentarios', ...) — el guion en la ruta genera automáticamente /articulos/{articulo}/comentarios/{comentario}.
  • Para el test de 422: envía un artículo sin titulo y comprueba $response->assertStatus(422)->assertJsonValidationErrors('titulo').

🧠 Autoevaluación

Porque separa la responsabilidad de "¿son válidos estos datos?" de "¿qué hago con ellos?". El controlador queda delgado y legible, la regla de validación es reutilizable y testeable por separado, y Laravel devuelve automáticamente un 422 con los errores si falla — sin código repetido en cada acción.

Con $fillable vacío, Eloquent bloquea la asignación masiva de TODOS los campos — el create fallaría silenciosamente o lanzaría una excepción según la configuración. Sin $fillable definido en absoluto, cualquier campo del request (incluidos campos que no deberían ser editables por el usuario, como es_admin) se asignaría — el vector clásico de mass assignment.

Es hacer 1 query para listar N registros y luego N queries adicionales (una por registro) para cargar una relación — 101 queries para listar 100 artículos con su autor, en vez de 2. Se detecta con DB::enableQueryLog() en desarrollo, o con paquetes como Laravel Debugbar que muestran el conteo de queries por petición. Se arregla con with('autor') (eager loading).

Porque un blog público típicamente quiere que cualquiera pueda LEER los artículos (GET), pero solo usuarios autenticados puedan escribir (POST/PUT/DELETE). Es el mismo patrón de "lectura pública, escritura protegida" del cap. 00 — proteger todo por igual sería más simple pero rompería el caso de uso real de un blog.

Porque una migración es código versionado en git: se revisa en PR, se aplica igual en desarrollo/staging/producción, y se puede revertir (down()). Cambiar la BD a mano no deja rastro, no es reproducible en otro entorno, y es la causa clásica de "en mi máquina funciona" cuando el esquema de producción diverge silenciosamente del de desarrollo.

Laravel reintenta el job desde cero (las colas son at-least-once, no exactly-once), lo que volvería a ejecutar el cobro. Se evita haciendo el job idempotente: comprobar al principio del handle() si el efecto ya ocurrió (p. ej. if ($pedido->pagado) return;) antes de repetir la operación.

Centraliza la regla de autorización en un solo lugar por modelo, en vez de duplicarla (y arriesgarse a olvidarla) en cada acción. $this->authorize() además lanza automáticamente un 403 si falla, y la misma Policy se reutiliza en tests, Blade y otros controladores sin copiar la condición.


Siguiente: 04-python-flask.md — el mismo concepto, en Python minimalista.