Skip to content
Last9 named a Gartner Cool Vendor in AI for SRE Observability for 2025! Read more →
Last9

Laravel

Learn how to integrate OpenTelemetry with Laravel applications and send telemetry data to Last9

This guide shows you how to instrument your Laravel application with OpenTelemetry and send traces, metrics, and logs to Last9.

Prerequisites

  • PHP 8.1 or later
  • Laravel 9.x or Laravel 10.x/11.x
  • Composer
  • Last9 account with OTLP endpoint configured

Installation

  1. Install OpenTelemetry PHP extension

    Install the PHP extension following the OpenTelemetry PHP setup guide:

    # For Ubuntu/Debian
    sudo apt-get install php-dev
    pecl install opentelemetry
    # For macOS with Homebrew
    brew install php
    pecl install opentelemetry
    # Add to php.ini
    echo "extension=opentelemetry" >> $(php --ini | grep "Loaded Configuration" | sed -e "s|.*:\s*||")
  2. Install OpenTelemetry SDK and auto-instrumentation packages

    composer require \
    open-telemetry/sdk \
    open-telemetry/exporter-otlp \
    open-telemetry/opentelemetry-auto-laravel \
    open-telemetry/opentelemetry-auto-psr18 \
    open-telemetry/opentelemetry-auto-io \
    php-http/guzzle7-adapter
  3. Install additional instrumentation packages (optional)

    composer require \
    open-telemetry/opentelemetry-auto-pdo \
    open-telemetry/opentelemetry-auto-slim \
    monolog/monolog \
    predis/predis

Configuration

Environment Variables

# Application
APP_NAME=Laravel-App
APP_ENV=production
APP_DEBUG=false
# OpenTelemetry Configuration
OTEL_PHP_AUTOLOAD_ENABLED=true
OTEL_SERVICE_NAME=laravel-app
OTEL_EXPORTER_OTLP_ENDPOINT=$last9_otlp_endpoint
OTEL_EXPORTER_OTLP_HEADERS="Authorization=$last9_otlp_auth_header"
OTEL_TRACES_EXPORTER=otlp
OTEL_METRICS_EXPORTER=otlp
OTEL_LOGS_EXPORTER=otlp
OTEL_PROPAGATORS=baggage,tracecontext
OTEL_TRACES_SAMPLER=always_on
OTEL_RESOURCE_ATTRIBUTES="deployment.environment=production,service.version=1.0.0"
OTEL_LOG_LEVEL=error
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel_db
DB_USERNAME=user
DB_PASSWORD=password
# Redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
# Queue
QUEUE_CONNECTION=redis

Auto-Instrumentation Setup

<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Add OpenTelemetry middleware
$middleware->web(append: [
\App\Http\Middleware\OpenTelemetryMiddleware::class,
]);
$middleware->api(append: [
\App\Http\Middleware\OpenTelemetryMiddleware::class,
]);
})
->withExceptions(function (Exceptions $exceptions) {
// Exception handling will be instrumented automatically
})
->create();

Custom Middleware and Services

OpenTelemetry Middleware

<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
use OpenTelemetry\API\Globals;
use OpenTelemetry\API\Instrumentation\CachedInstrumentation;
use OpenTelemetry\API\Trace\Span;
use OpenTelemetry\API\Trace\SpanKind;
use OpenTelemetry\API\Trace\StatusCode;
use OpenTelemetry\Context\Context;
use OpenTelemetry\SemConv\TraceAttributes;
use Symfony\Component\HttpFoundation\Response;
class OpenTelemetryMiddleware
{
private CachedInstrumentation $instrumentation;
public function __construct()
{
$this->instrumentation = new CachedInstrumentation('laravel-middleware', '1.0.0');
}
public function handle(Request $request, Closure $next): Response
{
$tracer = $this->instrumentation->tracer();
$spanName = $this->getSpanName($request);
$span = $tracer->spanBuilder($spanName)
->setSpanKind(SpanKind::KIND_SERVER)
->setAttributes([
TraceAttributes::HTTP_REQUEST_METHOD => $request->getMethod(),
TraceAttributes::URL_FULL => $request->fullUrl(),
TraceAttributes::HTTP_ROUTE => $request->route()?->uri() ?? 'unknown',
TraceAttributes::USER_AGENT_ORIGINAL => $request->userAgent(),
TraceAttributes::CLIENT_ADDRESS => $request->ip(),
'laravel.route.name' => $request->route()?->getName(),
'laravel.route.action' => $request->route()?->getActionName(),
'request.body.size' => $request->server('CONTENT_LENGTH', 0),
])
->startSpan();
$scope = $span->activate();
$startTime = hrtime(true);
try {
// Store span in request for use in controllers
$request->attributes->set('otel.span', $span);
$request->attributes->set('otel.context', Context::getCurrent());
$response = $next($request);
// Record response attributes
$duration = (hrtime(true) - $startTime) / 1_000_000; // Convert to milliseconds
$span->setAttributes([
TraceAttributes::HTTP_RESPONSE_STATUS_CODE => $response->getStatusCode(),
'response.body.size' => strlen($response->getContent()),
'http.request.duration_ms' => $duration,
]);
// Set span status based on HTTP status code
if ($response->getStatusCode() >= 400) {
$span->setStatus(StatusCode::STATUS_ERROR, 'HTTP ' . $response->getStatusCode());
} else {
$span->setStatus(StatusCode::STATUS_OK);
}
// Log request completion
Log::info('Request completed', [
'method' => $request->getMethod(),
'url' => $request->fullUrl(),
'status' => $response->getStatusCode(),
'duration_ms' => $duration,
'trace_id' => $span->getContext()->getTraceId(),
'span_id' => $span->getContext()->getSpanId(),
]);
return $response;
} catch (\Throwable $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, $e->getMessage());
Log::error('Request failed', [
'method' => $request->getMethod(),
'url' => $request->fullUrl(),
'error' => $e->getMessage(),
'trace_id' => $span->getContext()->getTraceId(),
'span_id' => $span->getContext()->getSpanId(),
]);
throw $e;
} finally {
$span->end();
$scope->detach();
}
}
private function getSpanName(Request $request): string
{
$method = $request->getMethod();
$route = $request->route()?->uri() ?? $request->getPathInfo();
return "{$method} {$route}";
}
}

Controller Examples

<?php
namespace App\Http\Controllers;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Http\Resources\UserResource;
use App\Models\User;
use App\Services\TelemetryService;
use App\Services\UserService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
class UserController extends Controller
{
public function __construct(
private UserService $userService,
private TelemetryService $telemetry
) {}
public function index(Request $request): AnonymousResourceCollection
{
return $this->telemetry->traceOperation('user_controller.index', function (Span $span) use ($request) {
$perPage = $request->get('per_page', 15);
$search = $request->get('search');
$span->setAttributes([
'controller' => 'UserController',
'action' => 'index',
'per_page' => $perPage,
'has_search' => !empty($search),
]);
$users = $this->userService->getUsers($perPage, $search);
$span->setAttributes([
'users.count' => $users->count(),
'users.total' => $users->total(),
]);
return UserResource::collection($users);
}, [
'operation' => 'list_users',
'controller' => 'UserController',
]);
}
public function store(StoreUserRequest $request): JsonResponse
{
return $this->telemetry->traceOperation('user_controller.store', function (Span $span) use ($request) {
$validatedData = $request->validated();
$span->setAttributes([
'controller' => 'UserController',
'action' => 'store',
'user.email' => $validatedData['email'],
]);
$user = $this->userService->createUser($validatedData);
$span->setAttributes([
'user.created' => true,
'user.id' => $user->id,
]);
return response()->json([
'message' => 'User created successfully',
'data' => new UserResource($user),
], 201);
}, [
'operation' => 'create_user',
'controller' => 'UserController',
]);
}
public function show(User $user): JsonResponse
{
return $this->telemetry->traceOperation('user_controller.show', function (Span $span) use ($user) {
$span->setAttributes([
'controller' => 'UserController',
'action' => 'show',
'user.id' => $user->id,
'user.email' => $user->email,
]);
// Load relationships with tracing
$userWithRelations = $this->userService->getUserWithDetails($user->id);
$span->setAttributes([
'user.has_profile' => $userWithRelations->profile !== null,
'user.posts_count' => $userWithRelations->posts_count ?? 0,
]);
return response()->json([
'data' => new UserResource($userWithRelations),
]);
}, [
'operation' => 'show_user',
'controller' => 'UserController',
]);
}
public function update(UpdateUserRequest $request, User $user): JsonResponse
{
return $this->telemetry->traceOperation('user_controller.update', function (Span $span) use ($request, $user) {
$validatedData = $request->validated();
$span->setAttributes([
'controller' => 'UserController',
'action' => 'update',
'user.id' => $user->id,
'user.email' => $user->email,
]);
$updatedUser = $this->userService->updateUser($user, $validatedData);
$span->setAttributes([
'user.updated' => true,
'fields_updated' => array_keys($validatedData),
]);
return response()->json([
'message' => 'User updated successfully',
'data' => new UserResource($updatedUser),
]);
}, [
'operation' => 'update_user',
'controller' => 'UserController',
]);
}
public function destroy(User $user): JsonResponse
{
return $this->telemetry->traceOperation('user_controller.destroy', function (Span $span) use ($user) {
$span->setAttributes([
'controller' => 'UserController',
'action' => 'destroy',
'user.id' => $user->id,
]);
$this->userService->deleteUser($user);
$span->setAttributes([
'user.deleted' => true,
]);
return response()->json([
'message' => 'User deleted successfully',
]);
}, [
'operation' => 'delete_user',
'controller' => 'UserController',
]);
}
}

Service Layer Integration

<?php
namespace App\Services;
use App\Models\User;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Mail;
class UserService
{
public function __construct(
private TelemetryService $telemetry
) {}
public function getUsers(int $perPage = 15, ?string $search = null): LengthAwarePaginator
{
return $this->telemetry->traceOperation('user_service.get_users', function (Span $span) use ($perPage, $search) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'getUsers',
'per_page' => $perPage,
'has_search' => !empty($search),
]);
$query = User::query();
if ($search) {
$query->where(function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%");
});
$span->setAttributes(['search_term' => $search]);
}
$users = $query->paginate($perPage);
$span->setAttributes([
'results.count' => $users->count(),
'results.total' => $users->total(),
'results.pages' => $users->lastPage(),
]);
return $users;
});
}
public function getUserWithDetails(int $userId): User
{
return $this->telemetry->traceOperation('user_service.get_user_with_details', function (Span $span) use ($userId) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'getUserWithDetails',
'user.id' => $userId,
]);
// Try cache first
$cacheKey = "user_details_{$userId}";
return Cache::remember($cacheKey, 300, function () use ($userId, $span) {
$span->addEvent('cache_miss', ['cache_key' => "user_details_{$userId}"]);
$user = User::with(['profile', 'posts' => function ($query) {
$query->latest()->take(5);
}])
->withCount('posts')
->findOrFail($userId);
$span->setAttributes([
'user.found' => true,
'user.has_profile' => $user->profile !== null,
'user.posts_count' => $user->posts_count,
'cache.stored' => true,
]);
return $user;
});
});
}
public function createUser(array $data): User
{
return $this->telemetry->traceOperation('user_service.create_user', function (Span $span) use ($data) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'createUser',
'user.email' => $data['email'],
]);
return DB::transaction(function () use ($data, $span) {
$span->addEvent('transaction_started');
// Hash password
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
// Create user
$user = User::create($data);
$span->setAttributes([
'user.created' => true,
'user.id' => $user->id,
]);
// Send welcome email (async)
$this->sendWelcomeEmail($user);
// Clear relevant caches
$this->clearUserCaches();
$span->addEvent('transaction_completed');
return $user;
});
});
}
public function updateUser(User $user, array $data): User
{
return $this->telemetry->traceOperation('user_service.update_user', function (Span $span) use ($user, $data) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'updateUser',
'user.id' => $user->id,
'fields_to_update' => array_keys($data),
]);
return DB::transaction(function () use ($user, $data, $span) {
$span->addEvent('transaction_started');
// Hash password if provided
if (isset($data['password'])) {
$data['password'] = Hash::make($data['password']);
}
$user->update($data);
$span->setAttributes([
'user.updated' => true,
]);
// Clear cache for this user
$this->clearUserCache($user->id);
$span->addEvent('transaction_completed');
return $user->refresh();
});
});
}
public function deleteUser(User $user): bool
{
return $this->telemetry->traceOperation('user_service.delete_user', function (Span $span) use ($user) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'deleteUser',
'user.id' => $user->id,
]);
return DB::transaction(function () use ($user, $span) {
$span->addEvent('transaction_started');
// Soft delete or hard delete based on business logic
$deleted = $user->delete();
$span->setAttributes([
'user.deleted' => $deleted,
'deletion_type' => 'soft_delete',
]);
// Clear cache
$this->clearUserCache($user->id);
$span->addEvent('transaction_completed');
return $deleted;
});
});
}
private function sendWelcomeEmail(User $user): void
{
$this->telemetry->traceOperation('user_service.send_welcome_email', function (Span $span) use ($user) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'sendWelcomeEmail',
'user.id' => $user->id,
'user.email' => $user->email,
]);
try {
// Queue welcome email (this will be traced by Laravel's queue instrumentation)
Mail::to($user->email)->queue(new \App\Mail\WelcomeEmail($user));
$span->setAttributes([
'email.queued' => true,
'email.type' => 'welcome',
]);
} catch (\Exception $e) {
$span->recordException($e);
$span->setStatus(StatusCode::STATUS_ERROR, 'Failed to queue welcome email');
// Log but don't fail user creation
Log::error('Failed to queue welcome email', [
'user_id' => $user->id,
'error' => $e->getMessage(),
]);
}
});
}
private function clearUserCache(int $userId): void
{
$this->telemetry->traceOperation('user_service.clear_user_cache', function (Span $span) use ($userId) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'clearUserCache',
'user.id' => $userId,
]);
$keys = [
"user_details_{$userId}",
"user_posts_{$userId}",
"user_profile_{$userId}",
];
foreach ($keys as $key) {
Cache::forget($key);
}
$span->setAttributes([
'cache.keys_cleared' => count($keys),
]);
});
}
private function clearUserCaches(): void
{
$this->telemetry->traceOperation('user_service.clear_user_caches', function (Span $span) {
$span->setAttributes([
'service' => 'UserService',
'operation' => 'clearUserCaches',
]);
// Clear user list cache
Cache::tags(['users'])->flush();
$span->setAttributes([
'cache.tags_cleared' => ['users'],
]);
});
}
}

Database Integration

Laravel’s Eloquent ORM is automatically instrumented when you install the OpenTelemetry auto-instrumentation packages. However, you can add custom attributes:

<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use OpenTelemetry\API\Trace\Span;
class User extends Authenticatable
{
use HasFactory, Notifiable;
protected $fillable = [
'name',
'email',
'password',
];
protected $hidden = [
'password',
'remember_token',
];
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
// Automatically add tracing attributes to model events
protected static function booted(): void
{
static::created(function (User $user) {
$span = Span::getCurrent();
if ($span) {
$span->addEvent('user.created', [
'user.id' => $user->id,
'user.email' => $user->email,
]);
}
});
static::updated(function (User $user) {
$span = Span::getCurrent();
if ($span) {
$span->addEvent('user.updated', [
'user.id' => $user->id,
'changes' => array_keys($user->getChanges()),
]);
}
});
static::deleted(function (User $user) {
$span = Span::getCurrent();
if ($span) {
$span->addEvent('user.deleted', [
'user.id' => $user->id,
]);
}
});
}
public function profile()
{
return $this->hasOne(UserProfile::class);
}
public function posts()
{
return $this->hasMany(Post::class);
}
}

Queue Integration

<?php
namespace App\Jobs;
use App\Models\User;
use App\Services\TelemetryService;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Mail;
class ProcessUserRegistration implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
private User $user
) {}
public function handle(TelemetryService $telemetry): void
{
$telemetry->traceOperation('process_user_registration', function (Span $span) {
$span->setAttributes([
'job' => 'ProcessUserRegistration',
'user.id' => $this->user->id,
'user.email' => $this->user->email,
'queue.name' => $this->queue ?? 'default',
]);
// Send welcome email
$telemetry->traceOperation('send_welcome_email', function (Span $emailSpan) {
$emailSpan->setAttributes([
'email.type' => 'welcome',
'email.recipient' => $this->user->email,
]);
Mail::to($this->user)->send(new \App\Mail\WelcomeEmail($this->user));
$emailSpan->setAttributes([
'email.sent' => true,
]);
});
// Create user profile
$telemetry->traceOperation('create_user_profile', function (Span $profileSpan) {
$profile = $this->user->profile()->create([
'bio' => '',
'avatar' => null,
]);
$profileSpan->setAttributes([
'profile.created' => true,
'profile.id' => $profile->id,
]);
});
$span->setAttributes([
'registration.processed' => true,
]);
}, [
'job.class' => static::class,
'job.queue' => $this->queue ?? 'default',
]);
}
public function failed(\Throwable $exception): void
{
$telemetry = app(TelemetryService::class);
$telemetry->recordException($exception);
$telemetry->addAttributes([
'job.failed' => true,
'job.class' => static::class,
'user.id' => $this->user->id,
]);
}
}

Production Deployment

Docker Configuration

FROM php:8.2-fpm-alpine
# Install system dependencies
RUN apk add --no-cache \
curl \
git \
icu-dev \
libzip-dev \
oniguruma-dev \
autoconf \
g++ \
make
# Install PHP extensions
RUN docker-php-ext-install \
pdo_mysql \
mbstring \
zip \
intl \
opcache
# Install OpenTelemetry extension
RUN pecl install opentelemetry && docker-php-ext-enable opentelemetry
# Install Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
# Set working directory
WORKDIR /var/www/html
# Copy composer files
COPY composer.json composer.lock ./
RUN composer install --no-dev --optimize-autoloader --no-scripts
# Copy application files
COPY . .
# Set permissions
RUN chown -R www-data:www-data /var/www/html
RUN chmod -R 755 /var/www/html/storage
# Copy PHP configuration
COPY docker/php/php.ini /usr/local/etc/php/php.ini
COPY docker/php/opcache.ini /usr/local/etc/php/conf.d/opcache.ini
# Generate optimized autoload
RUN composer dump-autoload --optimize
# Run Laravel optimizations
RUN php artisan config:cache && \
php artisan route:cache && \
php artisan view:cache
USER www-data
EXPOSE 9000
CMD ["php-fpm"]

Kubernetes Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
name: laravel-app
labels:
app: laravel-app
spec:
replicas: 3
selector:
matchLabels:
app: laravel-app
template:
metadata:
labels:
app: laravel-app
spec:
containers:
- name: laravel-app
image: your-registry/laravel-app:latest
ports:
- containerPort: 9000
env:
- name: OTEL_PHP_AUTOLOAD_ENABLED
value: "true"
- name: OTEL_SERVICE_NAME
value: "laravel-app"
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "$last9_otlp_endpoint"
- name: OTEL_EXPORTER_OTLP_HEADERS
value: "Authorization=$last9_otlp_auth_header"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "deployment.environment=production,service.version=1.0.0,k8s.cluster.name=production"
- name: APP_ENV
value: "production"
- name: DB_HOST
valueFrom:
secretKeyRef:
name: laravel-secrets
key: db-host
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: laravel-secrets
key: db-password
livenessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
tcpSocket:
port: 9000
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
---
apiVersion: v1
kind: Service
metadata:
name: laravel-app-service
spec:
selector:
app: laravel-app
ports:
- protocol: TCP
port: 9000
targetPort: 9000

Testing the Integration

  1. Start your application

    # Local development
    php artisan serve
    # Or with Docker
    docker-compose up
  2. Test the endpoints

    # API endpoints
    curl http://localhost:8000/api/users
    curl http://localhost:8000/api/users/1
    # Create a user
    curl -X POST http://localhost:8000/api/users \
    -H "Content-Type: application/json" \
    -d '{"name": "Test User", "email": "test@example.com", "password": "password123"}'
  3. View telemetry in Last9

    Check your Last9 dashboard for:

    • HTTP request traces with Laravel-specific attributes
    • Eloquent ORM database operation spans
    • Queue job execution traces
    • Cache operation spans
    • Custom business logic spans and events