Introducción a los Microservicios en Laravel: Guía básica

- Andrés Cruz - EN In english

Video thumbnail

En este post exploraremos los conceptos fundamentales de una arquitectura de microservicios utilizando Laravel. Para ilustrar este modelo, desarrollaremos un ejemplo práctico compuesto por dos aplicaciones independientes: un servicio de usuarios (user-service) y un servicio de gestión de tareas (task-service).

1. El Principio de Independencia de Datos

A diferencia de una aplicación monolítica, en una arquitectura de microservicios cada servicio es completamente independiente y propietario de su capa de persistencia.

 user-servicetask-service
Puertohttp://127.0.0.1:8000http://127.0.0.1:8001
Base de datosdatabase/db_users.sqlitedatabase/db_tasks.sqlite
Tablas propiasuserstasks, cache, jobs
¿Conoce usuarios?Sí, es el dueñoNo, solo guarda el user_id
¿Conoce tareas?NoSí, es el dueño
InterfazNinguna (headless)API REST + UI web Blade

Comunicación síncrona por HTTP entre dos microservicios Laravel independientes — y hacia dónde va desde aquí.

+-------------+      +-------------------------------------+      +------------------+
|             |      |                                     |      |                  |
|   Cliente   |----->|  task-service  (:8001)             |----->|  user-service    |
|  curl /     | POST |  db_tasks.sqlite                   |  GET |  (:8000)          |
|  Postman/UI |<-----|  NO tiene la tabla users           |<-----|  db_users.sqlite  |
|             | 201  |  valida el user_id por HTTP        | 200  |  (headless)      |
+-------------+      +-------------------------------------+ 404  +------------------+

Cómo leer este documento: secciones 1 a 11 = lo que está implementado y verificado hoy. Secciones 12 a 21 = qué sigue después, no implementado, es una hoja de ruta con el razonamiento detrás de cada decisión.

No existen llaves foráneas físicas (FOREIGN KEY) ni cruces mediante JOIN entre las bases de datos de ambos servicios. En el servicio de tareas, el campo user_id se almacena únicamente como un identificador numérico sin restricciones de base de datos distribuidas. Y esto es algo que puedes ver desde la migración de tareas:

task-service/database/migrations/2026_10_10_093642_create_tasks_table.php

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::create('tasks', function (Blueprint $table) {
            $table->id();
            $table->string('title');

            // Referencia lógica al user-service. NO es una llave foránea:
            // la tabla users vive en otra base de datos (db_users), en otro
            // proceso y en otro servicio. La integridad se valida por HTTP.
            $table->unsignedBigInteger('user_id')->index();

            $table->string('status')->default('pending');
            $table->timestamps();
        });
    }

2. Comunicación Síncrona mediante HTTP y el Patrón Service Client

Para garantizar que una tarea esté asociada a un usuario válido, el task-service realiza peticiones HTTP síncronas hacia el user-service utilizando un cliente dedicado.

Encapsulamiento con UserServiceClient

Toda la lógica de comunicación de red se aísla en un cliente de servicio que actúa como una frontera transparente, permitiendo que los controladores traten al microservicio externo como si fuera una función local:

task-service/app/Services/UserServiceClient.php

namespace App\Services;

class UserServiceClient
{
    public function find(int $id): ?array
    {
        $response = $this->request("/api/users/{$id}");

        if ($response->status() === 404) {
            return null;
        }

        if ($response->failed()) {
            throw new UserServiceErrorException("El user-service respondió {$response->status()}.");
        }

        return $response->json();
    }

    public function all(): array
    {
        $response = $this->request('/api/users');

        if ($response->failed()) {
            throw new UserServiceErrorException("El user-service respondió {$response->status()} al listar usuarios.");
        }

        return $response->json('data', []);
    }

    private function request(string $uri): Response
    {
        try {
            return Http::acceptJson()
                ->timeout($this->timeout())
                ->get($this->baseUrl().$uri);
        } catch (ConnectionException $exception) {
            throw new UserServiceUnavailableException('No se pudo contactar al user-service.', previous: $exception);
        }
    }
}

3. Endpoints del Servicio de Usuarios

El user-service opera de forma minimalista exponiendo un catálogo de usuarios en formato JSON:

user-service/routes/api.php

Route::get('/users', [UserController::class, 'index']);
Route::get('/users/{id}', [UserController::class, 'show'])->whereNumber('id');

 user-service/app/Http/Controllers/UserController.php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Http\JsonResponse;

class UserController extends Controller
{
    public function index(): JsonResponse
    {
        return response()->json(User::query()->orderBy('name')->paginate(50));
    }

    public function show(int $id): JsonResponse
    {
        $user = User::query()->find($id);

        if (! $user) {
            return response()->json(['error' => 'Usuario no encontrado'], 404);
        }

        return response()->json($user, 200);
    }
}

4. Gestión de Tareas y Validación en el TaskController

Cuando se procesa la creación o actualización de una tarea, el controlador valida la existencia del usuario consultando al servicio remoto antes de persistir la información:

task-service/app/Http/Controllers/TaskController.php

public function store(StoreTaskRequest $request, UserServiceClient $userService): RedirectResponse
{
    $validated = $request->validated();

    if ($userService->find($validated['user_id']) === null) {
        throw ValidationException::withMessages([
            'user_id' => 'Ese usuario no existe en el user-service.',
        ]);
    }

    Task::query()->create([
        ...$validated,
        'status' => $validated['status'] ?? Task::STATUS_PENDING,
    ]);

    return redirect()->route('tasks.index')->with('success', 'Tarea creada correctamente.');
}

También, implementamos el servicio tipo API, para que pueda ser consumida mediante una aplicación dedicada.

5. Consideraciones de Resiliencia y Degradación Elegante

Al trabajar en entornos distribuidos, la caída o lentitud de un microservicio dependiente no debe colapsar por completo la interfaz de usuario. Se implementan mecanismos como la función rescue de Laravel para permitir una experiencia degradada:

public function show(Task $task, UserServiceClient $userService): View
{
    return view('tasks.show', [
        'task' => $task,
        // Si el user-service no responde, la tarea se muestra sin el nombre del propietario
        'owner' => rescue(fn () => $userService->find($task->user_id), report: false),
    ]);
}

6. El flujo completo, paso a paso

  1. El cliente hace POST http://127.0.0.1:8001/api/tasks con { "title": "...", "user_id": 2 }.
  2. El task-service valida el payload (title requerido, user_id requerido/entero mayor o igual a 1). Si falla, responde 422 sin tocar la red.
  3. El task-service pide la lista de usuarios al user-service (GET /api/users) para poblar el formulario. Solo en la UI web.
  4. Al guardar, el task-service abre una conexión HTTP con Http::get hacia http://127.0.0.1:8000/api/users/{id}.
  5. El user-service consulta su propia tabla users:
    • 200 con el modelo JSON: el usuario existe.
    • 404 con {"error":"Usuario no encontrado"}: el usuario no existe.
  6. Según la respuesta, el task-service decide:
    • 200: inserta la tarea en db_tasks y responde 201 con la tarea creada.
    • 404: responde 422 y no escribe nada.
    • Conexión rechazada o timeout: responde 503.
    • Otro status (500, 502): responde 502.

Todo es síncrono bloqueante: el request del cliente espera a que el user-service conteste. Es el modelo más simple de explicar; la sección 13 explica cuándo y cómo dejar de hacerlo así.

7. Matriz de respuestas

Situaciónuser-servicetask-service¿Se guarda?
Payload inválido-422 (validación)No
user_id existe200201 + tareaSí
user_id no existe404422 "El usuario no existe en el sistema"No
user-service devuelve 500500502No
user-service apagado o timeout-503No

En los cuatro casos de fallo no se escribe nada en db_tasks. Ese es el invariante del flujo.

8. Cómo usarlo

Debes de usar dos terminales y para desarrollo vamos a usar el servicio de serve que provee artisan:

# Terminal 1
$ cd user-service
$ php artisan migrate:fresh --seed
$ php artisan serve --port=8000

# Terminal 2
$ cd task-service
$ php artisan migrate:fresh --seed
$ php artisan serve --port=8001

Y abrir http://127.0.0.1:8001/tasks.

Pruebas con cURL:

# user-service: lista
curl -s http://127.0.0.1:8000/api/users

# user-service: usuario existe -> 200
curl -i http://127.0.0.1:8000/api/users/1

# user-service: no existe -> 404
curl -i http://127.0.0.1:8000/api/users/999

# task-service: CRUD por API
curl -s -X POST http://127.0.0.1:8001/api/tasks \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"title":"Tarea end-to-end","user_id":2}'

curl -s http://127.0.0.1:8001/api/tasks
curl -s http://127.0.0.1:8001/api/tasks/1
curl -s -X PUT http://127.0.0.1:8001/api/tasks/1 \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"title":"Renombrada","user_id":1}'
curl -s -X DELETE http://127.0.0.1:8001/api/tasks/1

# usuario inválido -> 422 y NO se guarda nada
curl -s -X POST http://127.0.0.1:8001/api/tasks \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"title":"Tarea huerfana","user_id":77}'

# user-service apagado (Ctrl+C en su terminal) -> 503
curl -s -X POST http://127.0.0.1:8001/api/tasks \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"title":"Sin user-service","user_id":1}'

O Postman:

#MethodURLBody (raw / JSON)
1GET{{user_service}}/api/users-
2GET{{user_service}}/api/users/1-
3POST{{task_service}}/api/tasks{"title":"Tarea","user_id":2}
4GET{{task_service}}/api/tasks-
5PUT{{task_service}}/api/tasks/1{"title":"Nueva","status":"completed"}
6DELETE{{task_service}}/api/tasks/1-
{ "user_service": "http://127.0.0.1:8000", "task_service": "http://127.0.0.1:8001" }

9. Tests

cd user-service && vendor/bin/pest --compact   # 5 tests
cd task-service && vendor/bin/pest --compact   # 23 tests, 68 asserts

Ninguno levanta el user-service: usan Http::fake() para simular 200, 404, 500 y conexión caída. La suite es rápida y determinista, pero el contrato queda verificado:

Http::fake(['*' => Http::response(['error' => 'Usuario no encontrado'], 404)]);

$this->postJson('/api/tasks', ['title' => 'Tarea', 'user_id' => 42])->assertStatus(422);

expect(Task::query()->count())->toBe(0);   // nada se guardó

10. La prueba que demuestra que es un sistema distribuido

Detén el user-service (Ctrl+C) y manda un POST /api/tasks. El task-service responde 503 en milisegundos. Ninguna cantidad de código dentro del task-service pudo evitarlo: la información no está en su proceso, está en otro proceso, detrás de otra base de datos, al otro lado de un socket.

En la UI se ve igual de claro: el formulario de /tasks/create se degrada a un input numérico con un banner, y /tasks/{id} sigue mostrando las tareas sin el nombre del propietario.

Y a la inversa: borra user-service/ completo y el task-service sigue funcionando para listar, editar y borrar tareas. Solo pierde la capacidad de crear tareas nuevas. Esa es la disponibilidad parcial que da la independencia real entre servicios.

11. Limitaciones de este diseño

  • Sin circuit breaker: cada petición caída espera los 3 s completos del timeout.
  • Doble escritura sin transacción distribuida: si el user-service responde 200 y luego falla el insert, la validación "pasó" pero no hay tarea.
  • Acoplamiento al contrato: si el user-service cambia la forma del 404, el task-service se entera al instante.
  • Sin autenticación: cualquiera puede crear tareas. En localhost no importa.
  • SQLite con escritura concurrente: aguanta el demo, pero no es un motor de producción con varios workers escribiendo a la vez.

Conclusión y Próximos Pasos en Arquitecturas Distribuidas

Este ejemplo demuestra que incluso en aplicaciones sencillas, los microservicios introducen una complejidad considerable en la gestión de errores, latencia de red y consistencia de datos. A medida que los sistemas crecen, se deben incorporar arquitecturas asíncronas con colas (como RabbitMQ), pasarelas de API (API Gateways), autenticación centralizada y métricas de observabilidad para mantener un entorno de producción robusto.

Nada de esta sección está implementado. Es una hoja de ruta con el razonamiento detrás de cada decisión.

Anteriormente teníamos un microframework para Laravel llamado Lumen que era ideal para este tipo de servicios.

Lumen fue el framework "ligero" de Laravel para microservicios: menos dependencias, arranque más rápido. Laravel dejó de recomendarlo para proyectos nuevos en 2021, y así lo dice la documentación oficial:

Las tres razones por las que Lumen perdió

  1. Laravel alcanzó en rendimiento. El route caching, OPcache y la gestión de middleware cerraron la brecha. Lumen llegó a ser "casi igual de rápido" y bastante más incómodo.
  2. No tenía Sanctum, ni Passport, ni Blade, ni una UI decente. Para una API de microservicio terminabas reinyectando a mano los componentes de Laravel, que es Lumen dejando de ser Lumen.
  3. Octane resolvió el problema que Lumen venía a resolver, pero sin renunciar a nada.

Consecuencia práctica para nosotros: el argumento "es un microservicio, entonces Lumen" ya no se sostiene. La velocidad se compra con Octane, no con un framework distinto. Si vienes de un proyecto en Lumen, el camino es copiar los archivos a un Laravel limpio: Lumen corre sobre los mismos componentes illuminate/* que Laravel.

Cómo Proteger una API REST en Laravel con Firma Digital HMAC y Timestamps


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

Acepto recibir anuncios de interes sobre este Blog.