PHP Moderne 2024 : Bonnes Pratiques et Nouveautés

Découvrez le PHP moderne avec PHP 8.3+, nouvelles features, patterns avancés, et outils essentiels pour développer efficacement en 2024.

Image de l'article : PHP Moderne 2024 : Bonnes Pratiques et Nouveautés

PHP Moderne 2024 : Bonnes Pratiques et Nouveautés

PHP a énormément évolué ces dernières années. Avec PHP 8.3+ et les frameworks modernes comme Symfony, le langage offre des possibilités exceptionnelles. Voici mon guide complet du PHP moderne basé sur 8+ ans d’expérience.

1. PHP 8.3+ : Nouvelles Fonctionnalités

Typed Properties et Union Types

<?php

declare(strict_types=1);

class UserService
{
    // Typed properties (PHP 7.4+)
    private DatabaseConnection $connection;
    private LoggerInterface $logger;
    
    // Union types (PHP 8.0+)
    private string|int $identifier;
    
    // Nullable types
    private ?User $currentUser = null;
    
    // Readonly properties (PHP 8.1+)
    public readonly string $serviceId;

    public function __construct(
        DatabaseConnection $connection,
        LoggerInterface $logger,
        string|int $identifier,
        string $serviceId = null
    ) {
        $this->connection = $connection;
        $this->logger = $logger;
        $this->identifier = $identifier;
        $this->serviceId = $serviceId ?? uniqid('service_');
    }

    // Return type declarations
    public function findUser(int $id): ?User
    {
        try {
            $result = $this->connection->query(
                'SELECT * FROM users WHERE id = :id',
                ['id' => $id]
            );
            
            return $result ? User::fromArray($result) : null;
        } catch (DatabaseException $e) {
            $this->logger->error('Failed to find user', [
                'id' => $id,
                'error' => $e->getMessage()
            ]);
            return null;
        }
    }

    // Mixed type pour flexibilité
    public function processData(mixed $data): array
    {
        return match (gettype($data)) {
            'string' => $this->processString($data),
            'array' => $this->processArray($data),
            'object' => $this->processObject($data),
            default => ['error' => 'Unsupported data type']
        };
    }
}

Match Expression et Enums

<?php

// Enums (PHP 8.1+)
enum UserStatus: string
{
    case ACTIVE = 'active';
    case INACTIVE = 'inactive';
    case SUSPENDED = 'suspended';
    case PENDING = 'pending';
    
    public function getLabel(): string
    {
        return match($this) {
            self::ACTIVE => 'Utilisateur actif',
            self::INACTIVE => 'Utilisateur inactif',
            self::SUSPENDED => 'Compte suspendu',
            self::PENDING => 'En attente de validation'
        };
    }
    
    public function canLogin(): bool
    {
        return $this === self::ACTIVE;
    }
    
    public static function fromString(string $status): self
    {
        return match($status) {
            'active' => self::ACTIVE,
            'inactive' => self::INACTIVE,
            'suspended' => self::SUSPENDED,
            'pending' => self::PENDING,
            default => throw new InvalidArgumentException("Invalid status: {$status}")
        };
    }
}

// Backed Enums avec méthodes
enum HttpMethod: string
{
    case GET = 'GET';
    case POST = 'POST';
    case PUT = 'PUT';
    case DELETE = 'DELETE';
    case PATCH = 'PATCH';
    
    public function isReadOnly(): bool
    {
        return match($this) {
            self::GET => true,
            default => false
        };
    }
}

// Utilisation avec match expression
class UserController
{
    public function getUserStatusMessage(UserStatus $status): string
    {
        return match($status) {
            UserStatus::ACTIVE => 'Utilisateur connecté et actif',
            UserStatus::INACTIVE => 'Utilisateur temporairement inactif',
            UserStatus::SUSPENDED => 'Compte suspendu - contactez le support',
            UserStatus::PENDING => 'Validation du compte en cours'
        };
    }
    
    public function handleRequest(HttpMethod $method, array $data): array
    {
        return match($method) {
            HttpMethod::GET => $this->handleGet(),
            HttpMethod::POST => $this->handlePost($data),
            HttpMethod::PUT => $this->handlePut($data),
            HttpMethod::DELETE => $this->handleDelete($data),
            HttpMethod::PATCH => $this->handlePatch($data),
        };
    }
}

Attributes et Reflection Moderne

<?php

// Attributes personnalisés (PHP 8.0+)
#[Attribute(Attribute::TARGET_METHOD)]
class Cache
{
    public function __construct(
        public int $ttl = 3600,
        public string $key = '',
        public array $tags = []
    ) {}
}

#[Attribute(Attribute::TARGET_PROPERTY)]
class Validate
{
    public function __construct(
        public array $rules = [],
        public string $message = ''
    ) {}
}

#[Attribute(Attribute::TARGET_CLASS)]
class ApiResource
{
    public function __construct(
        public string $route = '',
        public array $methods = ['GET']
    ) {}
}

// Utilisation des attributes
#[ApiResource(route: '/api/users', methods: ['GET', 'POST'])]
class User
{
    #[Validate(rules: ['required', 'email'], message: 'Email invalide')]
    private string $email;
    
    #[Validate(rules: ['required', 'min:3', 'max:50'])]
    private string $name;
    
    public function __construct(string $email, string $name)
    {
        $this->email = $email;
        $this->name = $name;
    }
    
    #[Cache(ttl: 1800, key: 'user_profile_{id}', tags: ['user', 'profile'])]
    public function getProfile(): array
    {
        return [
            'email' => $this->email,
            'name' => $this->name,
            'created_at' => new DateTime()
        ];
    }
}

// Service utilisant la reflection pour les attributes
class AttributeProcessor
{
    public function processCaching(object $instance, string $method, array $args = []): mixed
    {
        $reflection = new ReflectionMethod($instance, $method);
        $attributes = $reflection->getAttributes(Cache::class);
        
        if (empty($attributes)) {
            return $instance->$method(...$args);
        }
        
        $cache = $attributes[0]->newInstance();
        $cacheKey = $this->generateCacheKey($cache->key, $instance, $args);
        
        // Vérifier le cache
        if ($cached = $this->getCachedResult($cacheKey)) {
            return $cached;
        }
        
        // Exécuter la méthode et mettre en cache
        $result = $instance->$method(...$args);
        $this->setCacheResult($cacheKey, $result, $cache->ttl, $cache->tags);
        
        return $result;
    }
    
    public function validateObject(object $instance): array
    {
        $errors = [];
        $reflection = new ReflectionClass($instance);
        
        foreach ($reflection->getProperties() as $property) {
            $attributes = $property->getAttributes(Validate::class);
            
            if (empty($attributes)) {
                continue;
            }
            
            $validate = $attributes[0]->newInstance();
            $property->setAccessible(true);
            $value = $property->getValue($instance);
            
            foreach ($validate->rules as $rule) {
                if (!$this->validateRule($value, $rule)) {
                    $errors[$property->getName()][] = $validate->message ?: "Validation failed for rule: {$rule}";
                }
            }
        }
        
        return $errors;
    }
    
    private function validateRule(mixed $value, string $rule): bool
    {
        return match($rule) {
            'required' => !empty($value),
            'email' => filter_var($value, FILTER_VALIDATE_EMAIL) !== false,
            default => str_starts_with($rule, 'min:') 
                ? strlen($value) >= (int)substr($rule, 4)
                : str_starts_with($rule, 'max:')
                    ? strlen($value) <= (int)substr($rule, 4)
                    : true
        };
    }
}

2. Architecture Moderne avec Symfony

Dependency Injection Avancée

<?php

namespace App\Service;

use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Log\LoggerInterface;

// Service avec injection de dépendances
#[AsAlias(UserServiceInterface::class)]
class UserService implements UserServiceInterface
{
    public function __construct(
        private readonly HttpClientInterface $httpClient,
        private readonly LoggerInterface $logger,
        private readonly CacheInterface $cache,
        #[Autowire('%app.api_url%')] private readonly string $apiUrl,
        #[Autowire(service: 'app.user_transformer')] private readonly UserTransformerInterface $transformer
    ) {}

    public function fetchUser(int $id): ?User
    {
        $cacheKey = "user_{$id}";
        
        // Vérifier le cache d'abord
        $cached = $this->cache->getItem($cacheKey);
        if ($cached->isHit()) {
            return $cached->get();
        }
        
        try {
            $response = $this->httpClient->request('GET', "{$this->apiUrl}/users/{$id}");
            
            if ($response->getStatusCode() !== 200) {
                return null;
            }
            
            $userData = $response->toArray();
            $user = $this->transformer->transform($userData);
            
            // Mise en cache
            $cached->set($user);
            $cached->expiresAfter(3600); // 1 heure
            $this->cache->save($cached);
            
            return $user;
            
        } catch (Exception $e) {
            $this->logger->error('Failed to fetch user', [
                'id' => $id,
                'error' => $e->getMessage()
            ]);
            return null;
        }
    }
}

// Configuration des services
// config/services.yaml
/*
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\Service\UserService:
        arguments:
            $apiUrl: '%env(API_URL)%'
            
    app.user_transformer:
        class: App\Transformer\UserTransformer
        
    App\Service\:
        resource: '../src/Service/'
        
    # Configuration du HTTP Client
    framework:
        http_client:
            default_options:
                timeout: 10
                retry_failed: true
                max_retries: 3
*/

DTOs et Validation Avancée

<?php

namespace App\DTO;

use Symfony\Component\Validator\Constraints as Assert;

class CreateUserRequest
{
    #[Assert\NotBlank(message: 'L\'email ne peut pas être vide')]
    #[Assert\Email(message: 'Format d\'email invalide')]
    #[Assert\Length(max: 255, maxMessage: 'L\'email ne peut pas dépasser {{ limit }} caractères')]
    public string $email;

    #[Assert\NotBlank(message: 'Le nom ne peut pas être vide')]
    #[Assert\Length(min: 2, max: 50, minMessage: 'Le nom doit contenir au moins {{ limit }} caractères', maxMessage: 'Le nom ne peut pas dépasser {{ limit }} caractères')]
    #[Assert\Regex(pattern: '/^[a-zA-ZÀ-ÿ\s]+$/', message: 'Le nom ne peut contenir que des lettres et espaces')]
    public string $name;

    #[Assert\NotBlank(message: 'Le mot de passe ne peut pas être vide')]
    #[Assert\Length(min: 8, minMessage: 'Le mot de passe doit contenir au moins {{ limit }} caractères')]
    #[Assert\Regex(pattern: '/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/', message: 'Le mot de passe doit contenir au moins une minuscule, une majuscule et un chiffre')]
    public string $password;

    #[Assert\Choice(choices: ['user', 'admin', 'moderator'], message: 'Rôle invalide')]
    public string $role = 'user';

    #[Assert\Type(type: 'array')]
    #[Assert\All([
        new Assert\Type(type: 'string'),
        new Assert\Length(min: 1, max: 20)
    ])]
    public array $tags = [];

    // Factory method pour créer depuis une requête
    public static function fromArray(array $data): self
    {
        $dto = new self();
        $dto->email = $data['email'] ?? '';
        $dto->name = $data['name'] ?? '';
        $dto->password = $data['password'] ?? '';
        $dto->role = $data['role'] ?? 'user';
        $dto->tags = $data['tags'] ?? [];
        
        return $dto;
    }

    public function toArray(): array
    {
        return [
            'email' => $this->email,
            'name' => $this->name,
            'password' => $this->password,
            'role' => $this->role,
            'tags' => $this->tags
        ];
    }
}

// Utilisation dans un contrôleur
#[Route('/api/users', methods: ['POST'])]
class CreateUserController extends AbstractController
{
    public function __construct(
        private readonly ValidatorInterface $validator,
        private readonly UserServiceInterface $userService,
        private readonly SerializerInterface $serializer
    ) {}

    public function __invoke(Request $request): JsonResponse
    {
        try {
            // Désérialisation de la requête
            $createUserRequest = $this->serializer->deserialize(
                $request->getContent(),
                CreateUserRequest::class,
                'json'
            );
            
            // Validation
            $violations = $this->validator->validate($createUserRequest);
            
            if (count($violations) > 0) {
                $errors = [];
                foreach ($violations as $violation) {
                    $errors[$violation->getPropertyPath()][] = $violation->getMessage();
                }
                
                return $this->json(['errors' => $errors], 400);
            }
            
            // Création de l'utilisateur
            $user = $this->userService->createUser($createUserRequest);
            
            return $this->json([
                'message' => 'Utilisateur créé avec succès',
                'user' => [
                    'id' => $user->getId(),
                    'email' => $user->getEmail(),
                    'name' => $user->getName(),
                    'role' => $user->getRole()
                ]
            ], 201);
            
        } catch (NotEncodableValueException $e) {
            return $this->json(['error' => 'JSON invalide'], 400);
        } catch (Exception $e) {
            $this->logger->error('Failed to create user', [
                'error' => $e->getMessage(),
                'request' => $request->getContent()
            ]);
            
            return $this->json(['error' => 'Erreur interne'], 500);
        }
    }
}

3. Testing Moderne avec PHPUnit

Tests Unitaires Avancés

<?php

namespace App\Tests\Unit\Service;

use App\Service\UserService;
use App\Service\UserServiceInterface;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Psr\Log\LoggerInterface;

class UserServiceTest extends TestCase
{
    private UserService $userService;
    private HttpClientInterface|MockObject $httpClient;
    private LoggerInterface|MockObject $logger;
    private CacheInterface|MockObject $cache;

    protected function setUp(): void
    {
        $this->httpClient = $this->createMock(HttpClientInterface::class);
        $this->logger = $this->createMock(LoggerInterface::class);
        $this->cache = $this->createMock(CacheInterface::class);
        
        $this->userService = new UserService(
            $this->httpClient,
            $this->logger,
            $this->cache,
            'https://api.example.com',
            new UserTransformer()
        );
    }

    #[Test]
    public function it_fetches_user_successfully(): void
    {
        // Arrange
        $userId = 123;
        $userData = [
            'id' => $userId,
            'email' => 'test@example.com',
            'name' => 'John Doe'
        ];

        $response = $this->createMock(ResponseInterface::class);
        $response->method('getStatusCode')->willReturn(200);
        $response->method('toArray')->willReturn($userData);

        $this->httpClient
            ->expects($this->once())
            ->method('request')
            ->with('GET', "https://api.example.com/users/{$userId}")
            ->willReturn($response);

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(false);
        $this->cache->method('getItem')->willReturn($cacheItem);

        // Act
        $user = $this->userService->fetchUser($userId);

        // Assert
        $this->assertInstanceOf(User::class, $user);
        $this->assertEquals($userId, $user->getId());
        $this->assertEquals('test@example.com', $user->getEmail());
    }

    #[Test]
    #[DataProvider('invalidUserDataProvider')]
    public function it_handles_invalid_responses(int $statusCode, ?array $userData): void
    {
        // Arrange
        $userId = 123;
        
        $response = $this->createMock(ResponseInterface::class);
        $response->method('getStatusCode')->willReturn($statusCode);
        
        if ($userData !== null) {
            $response->method('toArray')->willReturn($userData);
        }

        $this->httpClient->method('request')->willReturn($response);
        
        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(false);
        $this->cache->method('getItem')->willReturn($cacheItem);

        // Act
        $user = $this->userService->fetchUser($userId);

        // Assert
        $this->assertNull($user);
    }

    public static function invalidUserDataProvider(): array
    {
        return [
            'not found' => [404, null],
            'server error' => [500, null],
            'invalid data' => [200, ['invalid' => 'data']],
        ];
    }

    #[Test]
    public function it_returns_cached_user_when_available(): void
    {
        // Arrange
        $userId = 123;
        $expectedUser = new User($userId, 'cached@example.com', 'Cached User');

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(true);
        $cacheItem->method('get')->willReturn($expectedUser);
        
        $this->cache->method('getItem')->willReturn($cacheItem);
        
        // Ne devrait pas faire d'appel HTTP
        $this->httpClient->expects($this->never())->method('request');

        // Act
        $user = $this->userService->fetchUser($userId);

        // Assert
        $this->assertSame($expectedUser, $user);
    }

    #[Test]
    public function it_logs_errors_on_http_exception(): void
    {
        // Arrange
        $userId = 123;
        $exception = new TransportException('Network error');

        $this->httpClient
            ->method('request')
            ->willThrowException($exception);

        $cacheItem = $this->createMock(CacheItemInterface::class);
        $cacheItem->method('isHit')->willReturn(false);
        $this->cache->method('getItem')->willReturn($cacheItem);

        $this->logger
            ->expects($this->once())
            ->method('error')
            ->with('Failed to fetch user', [
                'id' => $userId,
                'error' => 'Network error'
            ]);

        // Act
        $user = $this->userService->fetchUser($userId);

        // Assert
        $this->assertNull($user);
    }
}

Tests d’Intégration avec Symfony

<?php

namespace App\Tests\Integration\Controller;

use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Response;

class UserControllerTest extends WebTestCase
{
    private $client;
    private $entityManager;

    protected function setUp(): void
    {
        $this->client = static::createClient();
        $this->entityManager = static::getContainer()
            ->get('doctrine')
            ->getManager();
    }

    #[Test]
    public function it_creates_user_successfully(): void
    {
        // Arrange
        $userData = [
            'email' => 'newuser@example.com',
            'name' => 'New User',
            'password' => 'SecurePass123',
            'role' => 'user',
            'tags' => ['developer', 'php']
        ];

        // Act
        $this->client->request(
            'POST',
            '/api/users',
            [],
            [],
            ['CONTENT_TYPE' => 'application/json'],
            json_encode($userData)
        );

        // Assert
        $this->assertResponseStatusCodeSame(Response::HTTP_CREATED);
        
        $responseData = json_decode($this->client->getResponse()->getContent(), true);
        $this->assertEquals('Utilisateur créé avec succès', $responseData['message']);
        $this->assertEquals('newuser@example.com', $responseData['user']['email']);

        // Vérifier en base de données
        $user = $this->entityManager
            ->getRepository(User::class)
            ->findOneBy(['email' => 'newuser@example.com']);
        
        $this->assertNotNull($user);
        $this->assertEquals('New User', $user->getName());
    }

    #[Test]
    public function it_validates_user_data(): void
    {
        // Arrange
        $invalidData = [
            'email' => 'invalid-email',
            'name' => 'A', // Trop court
            'password' => '123', // Trop faible
            'role' => 'invalid-role'
        ];

        // Act
        $this->client->request(
            'POST',
            '/api/users',
            [],
            [],
            ['CONTENT_TYPE' => 'application/json'],
            json_encode($invalidData)
        );

        // Assert
        $this->assertResponseStatusCodeSame(Response::HTTP_BAD_REQUEST);
        
        $responseData = json_decode($this->client->getResponse()->getContent(), true);
        $this->assertArrayHasKey('errors', $responseData);
        $this->assertArrayHasKey('email', $responseData['errors']);
        $this->assertArrayHasKey('name', $responseData['errors']);
        $this->assertArrayHasKey('password', $responseData['errors']);
    }

    protected function tearDown(): void
    {
        parent::tearDown();
        
        // Nettoyer la base de données de test
        $this->entityManager->close();
        $this->entityManager = null;
    }
}

4. Outils et Bonnes Pratiques 2024

Configuration PHPStan et Rector

// phpstan.neon
parameters:
    level: 9
    paths:
        - src
        - tests
    excludePaths:
        - src/Kernel.php
    checkMissingIterableValueType: false
    checkGenericClassInNonGenericObjectType: false
    ignoreErrors:
        - '#Unsafe usage of new static#'
    symfony:
        console_application_loader: tests/console-application.php
// rector.php
<?php

use Rector\Config\RectorConfig;
use Rector\Symfony\Set\SymfonySetList;
use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->paths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ]);

    $rectorConfig->sets([
        SymfonySetList::SYMFONY_64,
        SymfonySetList::SYMFONY_CODE_QUALITY,
        SymfonySetList::SYMFONY_CONSTRUCTOR_INJECTION,
    ]);

    $rectorConfig->rules([
        AddVoidReturnTypeWhereNoReturnRector::class,
    ]);

    $rectorConfig->skip([
        __DIR__ . '/src/Kernel.php',
    ]);
};

Docker pour le Développement

# Dockerfile
FROM php:8.3-fpm

# Extensions PHP essentielles
RUN apt-get update && apt-get install -y \
        git \
        unzip \
        libpng-dev \
        libonig-dev \
        libxml2-dev \
        libzip-dev \
        libpq-dev \
    && docker-php-ext-install \
        pdo \
        pdo_mysql \
        pdo_pgsql \
        mbstring \
        exif \
        pcntl \
        bcmath \
        gd \
        zip \
        opcache

# Composer
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer

# Configuration OPcache pour le développement
RUN echo "opcache.enable=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.validate_timestamps=1" >> /usr/local/etc/php/conf.d/opcache.ini \
    && echo "opcache.revalidate_freq=2" >> /usr/local/etc/php/conf.d/opcache.ini

# Xdebug pour le développement
RUN pecl install xdebug \
    && docker-php-ext-enable xdebug

COPY xdebug.ini /usr/local/etc/php/conf.d/xdebug.ini

WORKDIR /app

USER www-data
# docker-compose.yml
version: '3.8'

services:
  app:
    build: .
    volumes:
      - .:/app
    environment:
      - APP_ENV=dev
      - DATABASE_URL=mysql://user:password@db:3306/app_db
    depends_on:
      - db
      - redis

  nginx:
    image: nginx:alpine
    ports:
      - "80:80"
    volumes:
      - .:/app
      - ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
    depends_on:
      - app

  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: root
      MYSQL_DATABASE: app_db
      MYSQL_USER: user
      MYSQL_PASSWORD: password
    volumes:
      - db_data:/var/lib/mysql

  redis:
    image: redis:alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data

volumes:
  db_data:
  redis_data:

Conclusion

Le PHP moderne offre des outils puissants pour développer des applications robustes et performantes. Avec les bonnes pratiques, les outils appropriés et une architecture bien pensée, PHP reste un choix excellent pour le développement backend en 2024.

Points clés :

  • Utilisez les types stricts et les nouvelles fonctionnalités PHP 8.3+
  • Adoptez les patterns modernes avec Symfony
  • Implémentez une stratégie de test complète
  • Utilisez les outils d’analyse statique

Besoin d’aide avec vos projets PHP/Symfony ? Contactez-moi pour du développement, audit de code ou formation.


Mathieu Mont - Expert PHP Symfony avec 8+ ans d’expérience en développement backend