jurager / microservice
Microservice communication SDK for Laravel
Installs: 79
Dependents: 0
Suggesters: 0
Security: 0
Stars: 0
Watchers: 0
Forks: 0
Open Issues: 0
pkg:composer/jurager/microservice
Requires
- php: ^8.2
- guzzlehttp/guzzle: ^7.0
- illuminate/console: ^11.0 || ^12.0
- illuminate/http: ^11.0 || ^12.0
- illuminate/redis: ^11.0 || ^12.0
- illuminate/routing: ^11.0 || ^12.0
- illuminate/support: ^11.0 || ^12.0
Requires (Dev)
- orchestra/testbench: ^9.0 || ^10.0
- phpunit/phpunit: ^11.0
README
Laravel package for inter-service communication in microservice architecture. Provides HTTP transport with failover, HMAC signing, route discovery via Redis, and idempotency.
- Requirements
- Installation
- Schema
- Configuration
- Client
- Security
- Idempotency
- Route Discovery
- Health Tracking
- Events
- Artisan Commands
- Redis Keys
- Testing
Requirements
- PHP >= 8.2
- Laravel 11.x or higher
- Redis
- Guzzle 7+
Installation
composer require jurager/microservice
Publish the configuration file:
php artisan vendor:publish --tag=microservice-config
Schema
Each service can run multiple instances (base_urls in config). The client distributes requests across instances with automatic failover and health tracking.
Flow:
- Each microservice registers its routes in Redis via
microservice:register - The gateway reads manifests and creates Laravel routes via
Gateway::routes() - Incoming requests are proxied to the target service by
ProxyController - Services communicate directly via
ServiceClient(HMAC-signed, verified byTrustService) - Unhealthy instances are excluded from rotation; the client fails over to the next available instance
Configuration
config/microservice.php:
Service identity
'name' => env('SERVICE_NAME', 'app'), // unique service identifier 'secret' => env('SERVICE_SECRET', ''), // shared HMAC secret (openssl rand -base64 32) 'algorithm' => 'sha256', // HMAC hash algorithm 'timestamp_tolerance' => 60, // max age of signed request in seconds
All services in the cluster must use the same secret.
Services registry
'services' => [ 'oms' => [ 'base_urls' => ['http://oms-1:8000', 'http://oms-2:8000'], 'timeout' => 5, // optional, overrides defaults.timeout 'retries' => 2, // optional, overrides defaults.retries ], ],
Multiple base_urls enable failover — the client tries each instance in order until one responds.
Defaults
'defaults' => [ 'timeout' => 5, // request timeout in seconds 'retries' => 2, // retry attempts per instance 'retry_delay' => 100, // delay between retries in milliseconds ], 'redis' => [ 'connection' => env('SERVICE_REDIS_CONNECTION', 'default'), 'prefix' => 'microservice:', ], 'health' => [ 'failure_threshold' => 3, // failures before marking instance unhealthy 'recovery_timeout' => 30, // seconds before retrying unhealthy instance ], 'manifest' => [ 'ttl' => 300, // manifest TTL in Redis (seconds) 'prefix' => 'api', // only routes with this URI prefix are registered 'gateway' => env('MANIFEST_GATEWAY_SERVICE'), // gateway service name, or null for local Redis ], 'idempotency' => [ 'ttl' => 60, // cached response TTL in seconds 'lock_timeout' => 10, // distributed lock TTL in seconds ],
Client
Sending requests
use Jurager\Microservice\Client\ServiceClient; $client = app(ServiceClient::class); // GET $response = $client->service('oms')->get('/api/orders/123')->send(); $order = $response->json('data'); // POST with body and custom headers $response = $client->service('oms') ->post('/api/orders', ['product_id' => 1, 'quantity' => 5]) ->withHeaders(['X-Locale' => 'en']) ->timeout(10) ->send(); // PUT, PATCH, DELETE $client->service('wms')->put('/api/stock/42', ['quantity' => 100])->send(); $client->service('wms')->patch('/api/stock/42', ['quantity' => 50])->send(); $client->service('oms')->delete('/api/orders/123')->send();
Request builder
| Method | Description |
|---|---|
get(path) |
GET request |
post(path, body?) |
POST request with optional body |
put(path, body?) |
PUT request with optional body |
patch(path, body?) |
PATCH request with optional body |
delete(path) |
DELETE request |
withHeaders(array) |
Merge additional headers (e.g. X-Locale, X-Request-Id) |
withQuery(array) |
Merge query parameters |
withBody(array) |
Override request body |
timeout(seconds) |
Override timeout for this request |
retries(count) |
Override retry count for this request |
send() |
Execute and return ServiceResponse |
Priority for timeout / retries: explicit method call > per-service config > defaults.
ServiceResponse
$response->status(); // 200 $response->ok(); // true for 2xx $response->failed(); // true for non-2xx $response->json(); // full decoded body as array $response->json('data.id'); // dot-notation access via data_get() $response->json('key', 'def'); // with default value $response->body(); // raw string $response->header('X-Total'); // single header value or null $response->headers(); // all headers as array $response->throw(); // throws ServiceRequestException if failed, returns $this otherwise $response->toPsrResponse(); // underlying PSR-7 ResponseInterface
Error handling
use Jurager\Microservice\Exceptions\ServiceUnavailableException; use Jurager\Microservice\Exceptions\ServiceRequestException; try { $response = $client->service('oms')->get('/api/orders/123')->send()->throw(); } catch (ServiceUnavailableException $e) { // All instances failed or no instances configured Log::error("Service unavailable: {$e->service}"); } catch (ServiceRequestException $e) { // Non-2xx response (thrown by ->throw()) $status = $e->response->status(); $body = $e->response->json(); }
| Exception | When |
|---|---|
ServiceUnavailableException |
All instances exhausted after retries, or service not configured |
ServiceRequestException |
Thrown by ->throw() when response is non-2xx |
InvalidRequestIdException |
X-Request-Id is not a valid UUID v4 (Idempotency middleware) |
DuplicateRequestException |
Concurrent request with the same X-Request-Id is already processing (returns 409 Conflict) |
InvalidCacheStateException |
Cached response data is corrupted (returns 500 Internal Server Error) |
Failover and retry
The client iterates through base_urls from the service config. For each instance it retries up to retries times with retry_delay between attempts.
- 5xx / connection errors — retry, then failover to the next instance
- 4xx responses — returned immediately, no retry
- If all healthy instances fail, the client falls back to the full instance list as a last resort
- If all instances are exhausted,
ServiceUnavailableExceptionis thrown
Security
HMAC signature
All outgoing requests via ServiceClient are signed automatically. Headers added to every request:
| Header | Description |
|---|---|
X-Signature |
HMAC signature |
X-Timestamp |
Unix timestamp |
X-Service-Name |
Sender service name (from microservice.name) |
X-Request-Id |
Unique request ID (auto-generated UUID, or custom via withHeaders) |
Content-Type |
application/json |
Signature payload:
payload = "{METHOD}\n{PATH}\n{TIMESTAMP}\n{BODY}"
signature = hash_hmac('sha256', payload, SERVICE_SECRET)
Body is encoded with JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES, or empty string if absent. Verification uses timing-safe hash_equals(). Requests older than timestamp_tolerance seconds are rejected.
TrustGateway middleware
Verifies HMAC signature on incoming requests. Apply to routes that receive requests from the gateway:
use Jurager\Microservice\Http\Middleware\TrustGateway; Route::middleware(TrustGateway::class)->group(function () { Route::apiResource('products', ProductController::class); });
Required headers: X-Signature, X-Timestamp. Returns 401 if missing or invalid.
TrustService middleware
Extends TrustGateway — additionally requires X-Service-Name header. Use for direct service-to-service routes:
use Jurager\Microservice\Http\Middleware\TrustService; Route::middleware(TrustService::class)->group(function () { Route::post('/internal/sync', [SyncController::class, 'handle']); });
Idempotency
The Idempotency middleware caches successful (2xx) responses by X-Request-Id header. Repeated requests with the same ID return the cached response without executing the handler.
Automatic on Gateway — Gateway::routes() automatically applies Idempotency middleware to all proxied routes:
use Jurager\Microservice\Gateway\Gateway; Route::middleware(['auth:sanctum'])->group(function () { Gateway::routes(); // Idempotency is applied automatically });
Manual on Backend — for service-level caching:
use Jurager\Microservice\Http\Middleware\Idempotency; Route::middleware([TrustService::class, Idempotency::class])->group(function () { Route::post('/api/orders', [OrderController::class, 'store']); });
Behavior:
- Only non-safe methods (
POST,PUT,PATCH,DELETE) with anX-Request-Idheader are processed - Safe methods (
GET,HEAD,OPTIONS) and requests withoutX-Request-Idpass through X-Request-Idmust be a valid UUID v4 — other formats are rejected with400 Bad Request- Cached responses are stored for 24 hours by default, ensuring consistent responses for retries
- A distributed lock prevents concurrent processing of the same request — returns
409 Conflict - Only successful (2xx) responses are cached; failed responses are not
- Cached responses include the
X-Idempotency-Cache-Hit: trueheader - Response headers
dateandset-cookieare excluded from cache
Configuration: microservice.idempotency.ttl (default: 86400 seconds = 24 hours) and microservice.idempotency.lock_timeout (lock TTL).
How to use idempotency from client side
Important: Clients MUST provide their own X-Request-Id (UUID v4). Requests without X-Request-Id will bypass idempotency.
use Illuminate\Support\Str; // Generate a unique UUID v4 for this request $requestId = Str::uuid()->toString(); // e.g., "550e8400-e29b-41d4-a716-446655440000" $response = $client->service('oms') ->post('/api/orders', ['product_id' => 1]) ->withHeaders(['X-Request-Id' => $requestId]) ->send();
Safe retry pattern — if the request fails due to network issues:
use Illuminate\Support\Str; $requestId = Str::uuid()->toString(); try { $response = $client->service('oms') ->post('/api/orders', ['product_id' => 1]) ->withHeaders(['X-Request-Id' => $requestId]) ->send(); } catch (ServiceUnavailableException $e) { // Network failure - retry with the SAME request ID sleep(1); $response = $client->service('oms') ->post('/api/orders', ['product_id' => 1]) ->withHeaders(['X-Request-Id' => $requestId]) // Same UUID! ->send(); // Check if the response is from cache (first request actually succeeded) if ($response->header('X-Idempotency-Cache-Hit') === 'true') { // The order was already created by the first request Log::info("Order created on first attempt (retrieved from cache)"); } }
Why 24 hours? This design guarantees response consistency even when the initial request may have failed on the client side but succeeded on the server. Clients can safely retry within 24 hours and receive the identical response.
Detecting cached responses: Check for the X-Idempotency-Cache-Hit: true header to identify responses served from cache.
Route Discovery
Services register their routes in Redis for automatic gateway discovery. This eliminates route duplication — routes are defined once in the microservice and automatically appear on the gateway.
Microservice setup
- Configure the gateway as a service and set
manifest.gateway:
// config/microservice.php 'services' => [ 'gateway' => [ 'base_urls' => ['http://gateway:8000'], ], ], 'manifest' => [ 'gateway' => 'gateway', // or env('MANIFEST_GATEWAY_SERVICE') 'prefix' => 'api', // only routes starting with 'api' are registered ],
- Attach metadata to routes (optional):
$route = Route::get('/products', [ProductController::class, 'index']) ->name('products.index'); $route->setAction(array_merge($route->getAction(), [ 'permissions' => ['products.view'], 'rate_limit' => 60, ]));
Any keys not in the excluded list are forwarded to the gateway as route metadata.
- Register on deploy and keep alive with the scheduler (manifest TTL = 300s by default):
php artisan microservice:register
// bootstrap/app.php ->withSchedule(function (Schedule $schedule) { $schedule->command('microservice:register')->everyFiveMinutes(); })
When the service stops and stops re-registering, the manifest expires from Redis and the gateway stops routing to it.
Excluded action keys
The following Laravel internal keys are automatically excluded from manifest metadata:
uses, controller, middleware, as, prefix, namespace, where, domain, excluded_middleware, withoutMiddleware
Receiving manifests (gateway)
The package auto-registers POST /microservice/manifest, protected by TrustService middleware. When a microservice runs microservice:register with manifest.gateway set, the manifest is pushed to this endpoint and stored in the gateway's Redis. No additional setup needed.
If manifest.gateway is null, the manifest is stored in local Redis (useful for single-node setups or testing).
Gateway — registering routes
Gateway::routes() reads manifests from Redis and registers them as real Laravel routes. Each route is proxied to the corresponding service by ProxyController.
// gateway/routes/api.php use Jurager\Microservice\Gateway\Gateway; Route::middleware(['auth:sanctum'])->group(function () { Gateway::routes(); });
All metadata from setAction() (permissions, rate_limit, etc.) is available on the gateway:
$request->route()->getAction('permissions'); // ['products.view'] $request->route()->getAction('rate_limit'); // 60 $request->route()->getAction('_service'); // 'pim'
Filter by service
Gateway::routes(services: ['pim', 'oms']);
Route prefixes
If services have overlapping URIs (e.g., both PIM and OMS expose /api/products), add a prefix per service:
use Jurager\Microservice\Gateway\GatewayRoutes; Gateway::routes(function (GatewayRoutes $routes) { $routes->service('pim')->prefix('catalog'); $routes->service('oms')->prefix('orders'); });
By default, each service is prefixed with its name. Custom prefix overrides the default:
| Service | Manifest URI | Gateway URI |
|---|---|---|
| pim (default) | /api/products |
/pim/api/products |
pim (prefix: catalog) |
/api/products |
/catalog/api/products |
oms (prefix: orders) |
/api/orders |
/orders/api/orders |
The ProxyController forwards the original path (without prefix) to the service.
Customizing gateway routes
use Jurager\Microservice\Gateway\GatewayRoutes; Gateway::routes(function (GatewayRoutes $routes) { // Middleware for all routes of a service $routes->service('pim')->middleware(['analytics']); // Middleware for a specific route (ProxyController stays) $routes->service('oms') ->post('/api/orders')->middleware(['audit']); // Override controller for a specific route $routes->service('pim') ->get('/api/products/{product}', [ProductController::class, 'show']); // Override controller + middleware $routes->service('pim') ->post('/api/products/import', [ImportController::class, 'store']) ->middleware(['queue']); });
All routes (including overridden) receive manifest metadata via $request->route()->getAction().
ProxyController
The default ProxyController proxies each request to the target service via ServiceClient:
- Forwards HTTP method, path, JSON body and query parameters
- Signs the request with HMAC automatically
- Sends
X-Forwarded-Host,X-Forwarded-Proto,X-Forwarded-PortandX-Forwarded-Prefixheaders to the backend - Returns the service response with original status code and headers
- Filters out
Transfer-EncodingandConnectionheaders - Benefits from failover, retry and health tracking
To replace the default controller for all routes:
Gateway::routes(controller: App\Http\Controllers\MyProxyController::class);
URL rewriting via TrustProxies
When the gateway proxies a request, ProxyController sends X-Forwarded-* headers to the backend service. When manifest.gateway is set, the package automatically configures Laravel's TrustProxies middleware to trust the calling proxy. No additional configuration is needed on the backend side.
Example: gateway at https://api.example.com proxies to PIM at http://pim:8000 with prefix pim:
| Header | Value |
|---|---|
X-Forwarded-Host |
api.example.com |
X-Forwarded-Proto |
https |
X-Forwarded-Prefix |
/pim |
With these headers, all URL generation on the backend (url(), route(), pagination links, etc.) will produce https://api.example.com/pim/... instead of http://pim:8000/....
Route caching
Gateway::routes() reads manifests from Redis on every request. For production, use Laravel's route cache:
php artisan route:cache
Routes are serialized to a file — no Redis calls at runtime. When a service pushes a new manifest via POST /microservice/manifest, the route cache is cleared automatically. Rebuild after all services have registered:
php artisan route:cache
Manual route resolution
For cases where you don't use Gateway::routes():
use Jurager\Microservice\Registry\RouteRegistry; $registry = app(RouteRegistry::class); // Find which service handles a specific endpoint $match = $registry->resolve('GET', '/api/products/123'); // ['service' => 'pim', 'method' => 'GET', 'uri' => '/api/products/{product}', 'permissions' => [...]] // Get all registered routes across all services $routes = $registry->getAllRoutes(); // [['service' => 'pim', 'method' => 'GET', 'uri' => '/api/products', 'name' => 'products.index'], ...] // Get all manifests keyed by service name $manifests = $registry->getAllManifests();
Permission checking (jurager/teams)
Create a middleware on the gateway that reads permissions from route metadata:
// gateway/app/Http/Middleware/CheckPermissions.php class CheckPermissions { public function handle(Request $request, Closure $next) { $permissions = $request->route()->getAction('permissions'); if (!empty($permissions)) { $team = $request->user()->currentTeam; if (!$request->user()->hasTeamPermission($team, $permissions)) { abort(403); } } return $next($request); } }
// gateway/routes/api.php Route::middleware(['auth:sanctum', CheckPermissions::class])->group(function () { Gateway::routes(); });
Health Tracking
The HealthRegistry tracks instance failures in Redis. An instance is marked unhealthy after failure_threshold consecutive failures. After recovery_timeout seconds, the instance is given another chance.
| Action | Effect |
|---|---|
markFailure(service, url) |
Increments failure counter, stored with TTL = recovery_timeout * 2 |
markSuccess(service, url) |
Deletes the health key entirely (full reset) |
Health is evaluated per-instance:
- Below threshold — instance is healthy, included in rotation
- At/above threshold, within recovery timeout — instance is excluded
- At/above threshold, beyond recovery timeout — instance gets another chance
php artisan microservice:health
+----------+--------------------+----------+---------------------+-----------+
| Service | URL | Failures | Last Failure | Status |
+----------+--------------------+----------+---------------------+-----------+
| oms | http://oms-1:8000 | 0 | - | healthy |
| oms | http://oms-2:8000 | 5 | 2025-01-15 14:30:00 | unhealthy |
+----------+--------------------+----------+---------------------+-----------+
Events
The package dispatches several events that you can listen to for monitoring, logging, or triggering custom logic.
| Event | When | Properties |
|---|---|---|
ServiceRequestFailed |
Every failed attempt (5xx, connection error) before failover | $service, $url, $method, $path, $statusCode, $message |
ServiceBecameUnavailable |
All instances of a service are exhausted and unavailable | $service, $attemptedUrls, $lastError |
ServiceHealthChanged |
An instance's health status changes (healthy ↔ unhealthy) | $service, $url, $isHealthy, $failureCount, $previousStatus |
HealthCheckFailed |
An instance fails a health check (increments failure counter) | $service, $url, $failureCount, $reason |
RoutesRegistered |
Service successfully registers its routes (manifest pushed/stored) | $service, $routes, $gateway |
ManifestReceived |
Gateway receives a manifest from a microservice | $service, $manifest, $routeCount |
IdempotentRequestDetected |
A duplicate request is detected and served from cache | $requestId, $method, $path, $cachedStatusCode |
Example Listeners
// app/Listeners/LogServiceFailure.php use Jurager\Microservice\Events\ServiceRequestFailed; class LogServiceFailure { public function handle(ServiceRequestFailed $event): void { Log::warning("Service request failed", [ 'service' => $event->service, 'url' => $event->url, 'method' => $event->method, 'path' => $event->path, 'status' => $event->statusCode, 'message' => $event->message, ]); } }
// app/Listeners/AlertOnServiceDown.php use Jurager\Microservice\Events\ServiceBecameUnavailable; class AlertOnServiceDown { public function handle(ServiceBecameUnavailable $event): void { // Send alert to Slack, PagerDuty, etc. Log::critical("Service completely unavailable", [ 'service' => $event->service, 'urls' => $event->attemptedUrls, 'error' => $event->lastError, ]); } }
// app/Listeners/TrackHealthChanges.php use Jurager\Microservice\Events\ServiceHealthChanged; class TrackHealthChanges { public function handle(ServiceHealthChanged $event): void { $status = $event->isHealthy ? 'recovered' : 'degraded'; Log::info("Service health changed: $status", [ 'service' => $event->service, 'url' => $event->url, 'failures' => $event->failureCount, 'previous' => $event->previousStatus, ]); } }
// app/Listeners/ClearCacheOnManifestUpdate.php use Jurager\Microservice\Events\ManifestReceived; class ClearCacheOnManifestUpdate { public function handle(ManifestReceived $event): void { // Clear application cache, warm up route cache, etc. Cache::tags(['routes', $event->service])->flush(); Log::info("Manifest updated", [ 'service' => $event->service, 'routes' => $event->routeCount, ]); } }
Register in EventServiceProvider or use attribute-based discovery.
Artisan Commands
| Command | Description |
|---|---|
microservice:register |
Build and register the service route manifest. Pushes to gateway if manifest.gateway is set, otherwise stores in local Redis. Displays registered routes in a table. |
microservice:health |
Display health status of all configured service instances with failure counts and status. |
Redis Keys
All keys are prefixed with microservice.redis.prefix (default microservice:).
| Key Pattern | Purpose | TTL |
|---|---|---|
{prefix}health:{service}:{md5(url)} |
Instance failure counter and last failure timestamp | recovery_timeout * 2 |
{prefix}manifest:{service} |
Service route manifest (JSON) | manifest.ttl (300s) |
{prefix}idempotency:{request_id} |
Cached response (status, headers, content) | idempotency.ttl (60s) |
{prefix}idempotency:{request_id}:lock |
Distributed processing lock | idempotency.lock_timeout (10s) |
Testing
composer test
The package uses Orchestra Testbench for testing. Redis interactions are mocked via Mockery — no running Redis instance required for tests.
