Content Index
- 1. The Principle of Data Independence
- 2. Synchronous Communication via HTTP and the Service Client Pattern
- Encapsulation with UserServiceClient
- 3. User Service Endpoints
- 4. Task Management and Validation in TaskController
- 5. Considerations on Resilience and Graceful Degradation
- 6. The complete workflow, step by step
- 7. Response Matrix
- 8. How to Use It
- 9. Tests
- 10. The Proof That It Is a Distributed System
- 11. Limitations of This Design
- Conclusion and Next Steps in Distributed Architectures
In this post, we will explore the fundamental concepts of a microservices architecture using Laravel. To illustrate this model, we will develop a practical example consisting of two independent applications: a user service (user-service) and a task management service (task-service).
1. The Principle of Data Independence
Unlike a monolithic application, in a microservices architecture each service is completely independent and owns its persistence layer.
| user-service | task-service | |
|---|---|---|
| Port | http://127.0.0.1:8000 | http://127.0.0.1:8001 |
| Database | database/db_users.sqlite | database/db_tasks.sqlite |
| Own tables | users | tasks, cache, jobs |
| Knows users? | Yes, it is the owner | No, it only stores the user_id |
| Knows tasks? | No | Yes, it is the owner |
| Interface | None (headless) | REST API + Blade web UI |
Synchronous HTTP communication between two independent Laravel microservices — and where it goes from here.
+-------------+ +-------------------------------------+ +------------------+
| | | | | |
| Client |----->| task-service (:8001) |----->| user-service |
| curl / | POST | db_tasks.sqlite | GET | (:8000) |
| Postman/UI |<-----| Does NOT have the users table |<-----| db_users.sqlite |
| | 201 | validates user_id via HTTP | 200 | (headless) |
+-------------+ +-------------------------------------+ 404 +------------------+
How to read this document: sections 1 to 11 = what is implemented and verified today. Sections 12 to 21 = what comes next, not implemented, it is a roadmap with the reasoning behind each decision.
There are no physical foreign keys (FOREIGN KEY) or JOIN joins between the databases of both services. In the task service, the user_id field is stored solely as a numeric identifier without distributed database constraints. And you can see this from the task migration:
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');
// Logical reference to user-service. NOT a foreign key:
// the users table lives in another database (db_users), in another
// process and in another service. Integrity is validated via HTTP.
$table->unsignedBigInteger('user_id')->index();
$table->string('status')->default('pending');
$table->timestamps();
});
}2. Synchronous Communication via HTTP and the Service Client Pattern
To ensure a task is associated with a valid user, the task-service makes synchronous HTTP requests to the user-service using a dedicated client.
Encapsulation with UserServiceClient
All network communication logic is isolated in a service client that acts as a transparent boundary, allowing controllers to treat the external microservice as if it were a local function:
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("The user-service responded with {$response->status()}.");
}
return $response->json();
}
public function all(): array
{
$response = $this->request('/api/users');
if ($response->failed()) {
throw new UserServiceErrorException("The user-service responded with {$response->status()} when listing users.");
}
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('Could not contact the user-service.', previous: $exception);
}
}
}3. User Service Endpoints
The user-service operates minimally by exposing a catalog of users in JSON format:
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' => 'User not found'], 404);
}
return response()->json($user, 200);
}
}4. Task Management and Validation in TaskController
When processing the creation or updating of a task, the controller validates the user's existence by querying the remote service before persisting the information:
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' => 'That user does not exist in the user-service.',
]);
}
Task::query()->create([
...$validated,
'status' => $validated['status'] ?? Task::STATUS_PENDING,
]);
return redirect()->route('tasks.index')->with('success', 'Task created successfully.');
}
Also, we implement the API-type service so it can be consumed through a dedicated application.
5. Considerations on Resilience and Graceful Degradation
When working in distributed environments, the failure or slowness of a dependent microservice must not completely crash the user interface. Mechanisms like Laravel's rescue function are implemented to allow a degraded experience:
public function show(Task $task, UserServiceClient $userService): View
{
return view('tasks.show', [
'task' => $task,
// If the user-service does not respond, the task is shown without the owner's name
'owner' => rescue(fn () => $userService->find($task->user_id), report: false),
]);
}
6. The complete workflow, step by step
- The client makes POST http://127.0.0.1:8001/api/tasks with { "title": "...", "user_id": 2 }.
- The task-service validates the payload (title required, user_id required/integer greater than or equal to 1). If it fails, it responds with 422 without touching the network.
- The task-service requests the list of users from the user-service (GET /api/users) to populate the form. Only in the web UI.
- When saving, the task-service opens an HTTP connection with Http::get towards http://127.0.0.1:8000/api/users/{id}.
- The user-service queries its own users table:
- 200 with the JSON model: the user exists.
- 404 with {"error":"User not found"}: the user does not exist.
- Depending on the response, the task-service decides:
- 200: inserts the task into db_tasks and responds 201 with the created task.
- 404: responds 422 and writes nothing.
- Connection refused or timeout: responds 503.
- Other status (500, 502): responds 502.
Everything is synchronous blocking: the client's request waits for the user-service to reply. This is the simplest model to explain; section 13 explains when and how to stop doing it this way.
7. Response Matrix
| Situation | user-service | task-service | Is it saved? |
|---|---|---|---|
| Invalid payload | - | 422 (validation) | No |
| user_id exists | 200 | 201 + task | Yes |
| user_id does not exist | 404 | 422 "The user does not exist in the system" | No |
| user-service returns 500 | 500 | 502 | No |
| user-service down or timeout | - | 503 | No |
In all four failure cases, nothing is written to db_tasks. That is the invariant of the flow.
8. How to Use It
You must use two terminals and for development we will use the serve service provided by 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=8001And open http://127.0.0.1:8001/tasks.
Tests with cURL:
# user-service: list
curl -s http://127.0.0.1:8000/api/users
# user-service: user exists -> 200
curl -i http://127.0.0.1:8000/api/users/1
# user-service: does not exist -> 404
curl -i http://127.0.0.1:8000/api/users/999
# task-service: CRUD via API
curl -s -X POST http://127.0.0.1:8001/api/tasks \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"title":"End-to-end task","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":"Renamed","user_id":1}'
curl -s -X DELETE http://127.0.0.1:8001/api/tasks/1
# invalid user -> 422 and NOTHING is saved
curl -s -X POST http://127.0.0.1:8001/api/tasks \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d '{"title":"Orphan task","user_id":77}'
# user-service down (Ctrl+C in its 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":"Without user-service","user_id":1}'Or Postman:
| # | Method | URL | Body (raw / JSON) |
|---|---|---|---|
| 1 | GET | {{user_service}}/api/users | - |
| 2 | GET | {{user_service}}/api/users/1 | - |
| 3 | POST | {{task_service}}/api/tasks | {"title":"Task","user_id":2} |
| 4 | GET | {{task_service}}/api/tasks | - |
| 5 | PUT | {{task_service}}/api/tasks/1 | {"title":"New","status":"completed"} |
| 6 | DELETE | {{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 assertsNone of them spin up the user-service: they use Http::fake() to simulate 200, 404, 500, and dropped connections. The suite is fast and deterministic, but the contract is verified:
Http::fake(['*' => Http::response(['error' => 'User not found'], 404)]);
$this->postJson('/api/tasks', ['title' => 'Task', 'user_id' => 42])->assertStatus(422);
expect(Task::query()->count())->toBe(0); // nothing was saved10. The Proof That It Is a Distributed System
Stop the user-service (Ctrl+C) and send a POST /api/tasks. The task-service responds with 503 in milliseconds. No amount of code inside the task-service could prevent it: the information is not in its process, it is in another process, behind another database, across a socket.
In the UI it is seen just as clearly: the /tasks/create form degrades to a numeric input with a banner, and /tasks/{id} continues showing the tasks without the owner's name.
And vice versa: delete user-service/ entirely and the task-service continues to work for listing, editing, and deleting tasks. It only loses the ability to create new tasks. That is the partial availability that real independence between services provides.
11. Limitations of This Design
- No circuit breaker: each dropped request waits the full 3 s timeout.
- Double write without distributed transaction: if the user-service returns 200 and then the insert fails, the validation "passed" but there is no task.
- Contract coupling: if the user-service changes the shape of the 404, the task-service finds out immediately.
- No authentication: anyone can create tasks. On localhost it doesn't matter.
- SQLite with concurrent writes: handles the demo, but is not a production engine with multiple workers writing at the same time.
Conclusion and Next Steps in Distributed Architectures
This example demonstrates that even in simple applications, microservices introduce considerable complexity in error management, network latency, and data consistency. As systems grow, asynchronous architectures with queues (such as RabbitMQ), API gateways, centralized authentication, and observability metrics must be incorporated to maintain a robust production environment.
None of this section is implemented. It is a roadmap with the reasoning behind each decision.
Previously we had a microframework for Laravel called Lumen that was ideal for this type of service.
Lumen was Laravel's "lightweight" framework for microservices: fewer dependencies, faster startup. Laravel stopped recommending it for new projects in 2021, and the official documentation states so:
The three reasons why Lumen lost
- Laravel caught up in performance. Route caching, OPcache, and middleware management closed the gap. Lumen ended up being "almost as fast" and quite a bit more awkward.
- It lacked Sanctum, Passport, Blade, and a decent UI. For a microservice API you ended up manually reinjecting Laravel components, which is Lumen ceasing to be Lumen.
- Octane solved the problem Lumen came to solve, but without giving up anything.
Practical consequence for us: the argument "it's a microservice, therefore Lumen" no longer holds up. Speed is bought with Octane, not with a different framework. If you come from a Lumen project, the path is to copy the files to a clean Laravel: Lumen runs on the same illuminate/* components as Laravel.