Skip to content
ExponaBLOG
PHP & LARAVEL DEVELOPMENT · DAY 007 / 100

PSR standards worth knowing in PHP & Laravel Development

Interoperability standards for PHP.

Expona Admin7 min read

Introduction

When you're working across multiple Laravel projects or integrating third-party packages, you quickly notice that some codebases feel natural to work with while others feel alien. That difference often comes down to adherence to standards. PSR standards worth knowing in PHP & Laravel Development represent the foundation of interoperability in the PHP ecosystem—they're the agreements that let packages, frameworks, and teams collaborate without friction.

In this article, we'll explore four critical PSR standards that directly impact how you structure code, organize files, handle HTTP, and manage logs. Understanding these standards moves you from writing code that works to writing code that works everywhere.

PSR-4: Autoloading

PSR-4 defines how PHP classes should be automatically loaded based on their namespace and file path. It's the standard that lets you write use App\Services\PaymentProcessor; and have PHP find and load the class without explicit require statements.

In Laravel, PSR-4 is configured in your composer.json file under the autoload key. When you generate a new Laravel project, you'll see something like this:

php
{
  "autoload": {
    "psr-4": {
      "App\\": "app/",
      "Database\\": "database/"
    }
  }
}

This tells Composer: any class in the App namespace will be found in the app/ directory. If you have a class App\Services\PaymentProcessor, it must live at app/Services/PaymentProcessor.php. One namespace level maps to one directory level.

Why does this matter? When you're consuming a package from Packagist or building an internal library, you want consistent class resolution. No surprises, no custom autoloaders, no manual file inclusion. Your IDE knows where to find classes, static analysis tools can validate imports, and new team members don't waste time hunting for files.

A real scenario: you're building a shared domain package for multiple Laravel applications. You define your PSR-4 namespace structure clearly, publish it to a private Composer repository, and every consuming app loads your classes identically. That consistency is PSR-4 at work.

PSR-12 and PER: Coding Style

PSR-12 is the modern PHP coding style standard that governs spacing, naming, indentation, and structure. It evolved from PSR-2 and provides enough flexibility for real-world codebases. The newer PER (PHP Evolving Recommendation) builds on PSR-12 and is the direction the PHP community is moving toward.

These standards cover:

  • Indentation: 4 spaces (not tabs)
  • Line length: soft limit of 120 characters
  • Method and class brace placement
  • Namespace and use statement organization
  • Visibility declarations on all methods and properties

Here's a practical example following PSR-12:

php
<?php

namespace App\Services;

use App\Models\Order;
use Illuminate\Database\Eloquent\Collection;

class OrderProcessor
{
    public function __construct(
        private readonly OrderRepository $repository,
        private readonly NotificationService $notifications,
    ) {}

    public function processOrders(Collection $orders): void
    {
        foreach ($orders as $order) {
            if ($this->isEligible($order)) {
                $this->repository->markProcessed($order);
                $this->notifications->send($order);
            }
        }
    }

    private function isEligible(Order $order): bool
    {
        return $order->status === 'pending'
            && $order->created_at->diffInDays() > 1;
    }
}

Notice the consistent structure: namespace first, then use statements grouped logically, constructor properties with visibility, and proper method spacing.

In Laravel teams, you'll typically enforce these standards using tools like Laravel Pint, which is built on PHP-CS-Fixer. Run pint in your CI/CD pipeline or as a pre-commit hook, and code style becomes automatic—no arguments in code reviews about tabs versus spaces.

The benefit extends beyond aesthetics. When everyone's code looks the same, code reviews focus on logic and correctness rather than style bikeshedding. New developers read the codebase faster. Static analysis tools work more reliably on standardized code.

PSR-7 and PSR-15: HTTP Messages and Middleware

PSR-7 defines immutable HTTP request and response objects. PSR-15 defines the middleware pipeline interface. Together, they create a contract for how HTTP handling works in PHP applications.

In Laravel, you don't directly instantiate PSR-7 objects—the framework abstracts that. But understanding the contract is crucial when building middleware that needs to work across different frameworks or when consuming PSR-compliant packages.

Here's the core concept: a PSR-7 RequestInterface and ResponseInterface are immutable. You can't modify a request object in place. Instead, you create a new object with your changes. This immutability guarantees that middleware further down the pipeline sees exactly what you intended to pass.

A PSR-15 middleware handler implements this interface:

php
<?php

namespace App\Http\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;

class ApiRateLimitMiddleware implements MiddlewareInterface
{
    public function process(
        ServerRequestInterface $request,
        RequestHandlerInterface $handler,
    ): ResponseInterface {
        $clientId = $request->getHeader('X-Client-ID')[0] ?? null;

        if ($this->isRateLimited($clientId)) {
            return $this->createErrorResponse(
                'Rate limit exceeded',
                429,
            );
        }

        return $handler->handle($request);
    }

    private function isRateLimited(string $clientId): bool
    {
        // Check cache or Redis for rate limit state
        return false;
    }

    private function createErrorResponse(
        string $message,
        int $statusCode,
    ): ResponseInterface {
        // Create and return a PSR-7 response
        return response()->json(
            ['error' => $message],
            $statusCode,
        );
    }
}

Why does this matter? Suppose you're integrating a third-party authentication package that uses PSR-15 middleware. Because both your code and the package conform to PSR-15, the integration is friction-free. The package's middleware plugs into your pipeline without custom adapters or glue code.

PSR-7 also enables logging and inspection. Middleware can examine request attributes, headers, and body content consistently across any framework using the standard. You can build request logging, security scanning, or performance monitoring tools that work on any PSR-7 compliant application.

PSR-3: Logging Interface

PSR-3 defines a logging interface that decouples your application code from the logging implementation. Instead of hard-coding Monolog or any specific logger, you type-hint against Psr\Log\LoggerInterface.

In Laravel, the framework handles PSR-3 compliance. When you inject Log or use the Log facade, you're using a PSR-3 compliant logger. But understanding the interface matters when building libraries or multi-tenant systems where logging behavior varies.

The PSR-3 interface defines these log levels: debug, info, notice, warning, error, critical, alert, and emergency. It also supports contextual data:

php
<?php

namespace App\Services;

use Psr\Log\LoggerInterface;

class PaymentGateway
{
    public function __construct(
        private readonly LoggerInterface $logger,
    ) {}

    public function charge(string $customerId, float $amount): void
    {
        try {
            $this->validateAmount($amount);
            $transactionId = $this->processCharge($customerId, $amount);

            $this->logger->info(
                'Payment processed successfully',
                [
                    'customer_id' => $customerId,
                    'amount' => $amount,
                    'transaction_id' => $transactionId,
                ],
            );
        } catch (InsufficientFundsException $e) {
            $this->logger->warning(
                'Payment declined due to insufficient funds',
                [
                    'customer_id' => $customerId,
                    'amount' => $amount,
                    'exception' => $e,
                ],
            );

            throw $e;
        } catch (\Exception $e) {
            $this->logger->error(
                'Payment processing error',
                [
                    'customer_id' => $customerId,
                    'exception' => $e,
                ],
            );

            throw $e;
        }
    }

    private function processCharge(string $customerId, float $amount): string
    {
        // External API call
        return 'txn_' . uniqid();
    }

    private function validateAmount(float $amount): void
    {
        if ($amount <= 0) {
            throw new \InvalidArgumentException('Amount must be positive');
        }
    }
}

The key insight: your PaymentGateway class doesn't know or care whether logs go to files, Slack, or a third-party service. That's determined by the logger implementation passed in. You could inject a different logger in tests, or swap logging backends in production without changing this code.

This is especially powerful in multi-tenant SaaS. Different customers might route logs to different destinations. With PSR-3, you bind different logger implementations per tenant and your business logic remains unchanged.

Common mistakes

  • Mixing namespace and file structure: Putting a class App\Services\OrderProcessor in app/OrderProcessor.php breaks PSR-4. The directory hierarchy must match namespace hierarchy exactly.
  • Ignoring code style in shared packages: If you publish a package and ignore PSR-12, it feels foreign to consuming projects. Code style consistency matters more in open-source or internal shared code.
  • Logging with string concatenation: Writing $this->logger->info('Processed order ' . $orderId) is wrong. Use context: $this->logger->info('Processed order', ['id' => $orderId]). The latter is parseable by log aggregation tools.
  • Assuming PSR-7 request objects are mutable: If you've only worked with Laravel's request class, you might forget that standard PSR-7 objects are immutable. Code that tries to mutate a PSR-7 request will fail silently or cause subtle bugs.
  • Building middleware that only works with Laravel: If you hard-code Laravel-specific features into middleware, it won't be reusable across frameworks. PSR-15 middleware should be framework-agnostic.

Key takeaways

  • PSR-4 autoloading ties namespaces to directory structures, eliminating manual class loading and enabling seamless package integration.
  • PSR-12 and PER coding standards ensure consistent, readable code across teams and projects; use tools like Laravel Pint to enforce them automatically.
  • PSR-7 and PSR-15 define immutable HTTP messages and middleware pipelines, making middleware portable across frameworks and reducing integration friction.
  • PSR-3 logging decouples your application from logging implementations, enabling flexible log routing and better testability.
  • Adhering to PSR standards in your own code and insisting on them in dependencies creates an ecosystem where packages compose cleanly and teams collaborate without friction.
  • Use static analysis and automated tooling (Pint, PHPStan, Psalm) to verify compliance; don't rely on manual code review alone.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles