MFormations
Modern PHP Engineering

Chapitre 8

08 — APIs PHP

08 — APIs PHP

Cours 08 — APIs PHP

08.1 REST API Design

Resource Naming

GET    /users                    → Liste d'utilisateurs
POST   /users                    → Créer un utilisateur
GET    /users/{id}               → Détail d'un utilisateur
PUT    /users/{id}               → Remplacer un utilisateur
PATCH  /users/{id}               → Modifier partiellement
DELETE /users/{id}               → Supprimer

GET    /users/{id}/posts         → Posts d'un utilisateur
POST   /users/{id}/posts         → Créer un post pour un utilisateur

GET    /posts                    → Liste des posts
GET    /posts/{id}               → Détail d'un post
GET    /posts/{id}/comments      → Commentaires d'un post

Pagination

<?php

// Standard : offset/limit ou cursor-based
// Réponse type :

{
    "data": [...],
    "meta": {
        "current_page": 1,
        "per_page": 20,
        "total": 150,
        "last_page": 8,
        "from": 1,
        "to": 20
    },
    "links": {
        "first": "https://api.example.com/users?page=1",
        "last": "https://api.example.com/users?page=8",
        "prev": null,
        "next": "https://api.example.com/users?page=2"
    }
}

Filtering, Sorting

# Filtering
GET /users?filter[name]=John
GET /users?filter[created_at][gt]=2025-01-01
GET /users?filter[role]=admin,editor

# Sorting
GET /users?sort=name
GET /users?sort=-created_at  (desc)
GET /users?sort=name,created_at

# Include (eager loading)
GET /users?include=posts,roles

# Fields (sparse fields)
GET /users?fields[users]=id,name,email
GET /users?include=posts&fields[posts]=id,title

HATEOAS

<?php

// Hypermedia As The Engine Of Application State
// Chaque réponse inclut des liens pour naviguer dans l'API

function hateoasResponse(array $data, array $links): array
{
    return [
        'data' => $data,
        '_links' => $links,
    ];
}

// Exemple pour /users/1
$response = hateoasResponse(
    data: [
        'id' => 1,
        'name' => 'Alice',
        'email' => 'alice@example.com',
    ],
    links: [
        'self' => ['href' => '/api/users/1', 'method' => 'GET'],
        'posts' => ['href' => '/api/users/1/posts', 'method' => 'GET'],
        'update' => ['href' => '/api/users/1', 'method' => 'PUT'],
        'delete' => ['href' => '/api/users/1', 'method' => 'DELETE'],
    ]
);

08.2 Laravel Sanctum

Sanctum est le package d'authentification API de Laravel. Léger et flexible.

Installation

composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate

SPA Authentication

<?php

// config/sanctum.php
return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', 'localhost,localhost:3000')),
    'guard' => ['web'],
    'expiration' => null,
    'middleware' => [
        'verify_csrf_token' => App\Http\Middleware\VerifyCsrfToken::class,
    ],
];

// Sanctum utilise les cookies de session pour les SPAs
// Pas de token, juste CSRF + session
// Sanctum::actingAs($user) dans les tests

Token Authentication (API)

<?php

use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

Route::post('/sanctum/token', function (Request $request) {
    $request->validate([
        'email' => 'required|email',
        'password' => 'required',
        'device_name' => 'required|string',
    ]);

    $user = User::where('email', $request->email)->first();

    if (!$user || !Hash::check($request->password, $user->password)) {
        throw ValidationException::withMessages([
            'email' => ['Les identifiants sont incorrects.'],
        ]);
    }

    return $user->createToken($request->device_name)->plainTextToken;
});

// Protéger une route
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
    return $request->user();
});

Token Abilities

<?php

// Créer un token avec des capacités
$token = $user->createToken('mobile', ['posts:read', 'posts:write']);
$token = $user->createToken('dashboard', ['admin:read', 'admin:write']);

// Vérifier les capacités
if ($request->user()->tokenCan('posts:write')) {
    // Peut créer/modifier des posts
}

// Route avec middleware
Route::middleware('auth:sanctum', 'abilities:posts:write')->post('/posts', function () {
    // Seulement si le token a la capacité posts:write
});

Route::middleware('auth:sanctum', 'ability:admin:read,admin:write')->group(function () {
    // Routes admin
});

Supprimer des tokens

<?php

// Supprimer le token courant (déconnexion)
$request->user()->currentAccessToken()->delete();

// Supprimer tous les tokens
$request->user()->tokens()->delete();

// Supprimer un token spécifique
$user->tokens()->where('id', $tokenId)->delete();

08.3 API Platform

API Platform est un framework d'API construit sur Symfony.

Installation

composer create-project api-platform/api-platform
# ou dans un projet Symfony existant :
composer require api-platform

Resources

<?php

namespace App\Entity;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\ApiFilter;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;

#[ORM\Entity]
#[ApiResource(
    operations: [
        new Get(),
        new GetCollection(),
        new Post(),
        new Put(),
        new Delete(),
    ],
    normalizationContext: ['groups' => ['book:read']],
    denormalizationContext: ['groups' => ['book:write']],
    paginationItemsPerPage: 20,
    paginationClientItemsPerPage: true,
)]
#[ApiFilter(SearchFilter::class, properties: ['title' => 'partial', 'author.name' => 'partial'])]
#[ApiFilter(OrderFilter::class, properties: ['id', 'title', 'createdAt'])]
#[ApiFilter(DateFilter::class, properties: ['createdAt'])]
class Book
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column]
    #[Assert\NotBlank]
    #[Groups(['book:read', 'book:write'])]
    private string $title;

    #[ORM\Column(type: 'text')]
    #[Groups(['book:read', 'book:write'])]
    private string $description;

    #[ORM\Column(type: 'date')]
    #[Groups(['book:read', 'book:write'])]
    private \DateTimeInterface $publishedAt;

    #[ORM\ManyToOne(inversedBy: 'books')]
    #[Groups(['book:read', 'book:write'])]
    private ?Author $author = null;

    // Getters/Setters...
}

Filters

<?php

// SearchFilter : partial, exact, start, end, word_start
// DateFilter : before, after, strictly_before, strictly_after
// OrderFilter : ASC, DESC
// RangeFilter : gt, gte, lt, lte
// BooleanFilter : true, false
// NumericFilter : opérateurs numériques
// ExistsFilter : null/non-null

use ApiPlatform\Doctrine\Orm\Filter\SearchFilter;
use ApiPlatform\Doctrine\Orm\Filter\DateFilter;
use ApiPlatform\Doctrine\Orm\Filter\OrderFilter;
use ApiPlatform\Doctrine\Orm\Filter\RangeFilter;

#[ApiFilter(RangeFilter::class, properties: ['price'])]
#[ApiFilter(SearchFilter::class, properties: [
    'title' => 'partial',
    'author.name' => 'partial',
    'isbn' => 'exact',
])]

Custom Operations

<?php

use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Symfony\Action\NotFoundAction;

#[ApiResource(
    operations: [
        new Get(controller: NotFoundAction::class, read: false), // Désactiver GET
        new Post(
            uriTemplate: '/books/{id}/publish',
            status: 202,
            messenger: true, // Message async
        ),
    ],
)]
class Book
{
    // ...
}

08.4 GraphQL avec Lighthouse

Lighthouse est un serveur GraphQL pour Laravel.

Installation

composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-config
php artisan lighthouse:ide-helper

Schema

# graphql/schema.graphql

type Query {
    users: [User!]! @paginate
    user(id: ID! @eq): User @find
    posts(
        title: String @where(operator: "like")
        published: Boolean @eq
        orderBy: _ @orderBy
    ): [Post!]! @paginate
}

type Mutation {
    createUser(input: CreateUserInput! @spread): User! @create
    updateUser(id: ID!, input: UpdateUserInput! @spread): User! @update
    deleteUser(id: ID!): User! @delete
    login(email: String!, password: String!): AuthPayload! @field(resolver: "AuthMutator@login")
}

type User {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]! @hasMany
    createdAt: DateTime!
    updatedAt: DateTime!
}

type Post {
    id: ID!
    title: String!
    body: String!
    user: User! @belongsTo
    comments: [Comment!]! @hasMany
    createdAt: DateTime!
}

type Comment {
    id: ID!
    body: String!
    user: User! @belongsTo
    post: Post! @belongsTo
}

input CreateUserInput {
    name: String! @rules(apply: ["min:2", "max:255"])
    email: String! @rules(apply: ["email", "unique:users,email"])
    password: String! @rules(apply: ["min:8"]) @hash
}

type AuthPayload {
    token: String!
    user: User!
}

Resolvers

<?php

namespace App\GraphQL\Mutations;

use App\Models\User;
use Illuminate\Support\Facades\Hash;

class AuthMutator
{
    public function login($root, array $args): array
    {
        $user = User::where('email', $args['email'])->first();

        if (!$user || !Hash::check($args['password'], $user->password)) {
            throw new \Exception('Identifiants incorrects');
        }

        return [
            'token' => $user->createToken('graphql')->plainTextToken,
            'user' => $user,
        ];
    }
}

Directives

# Directives principales Lighthouse

@paginate          # Pagination automatique (paginator)
@find              # Find par ID
@create            # CREATE mutation
@update            # UPDATE mutation
@delete            # DELETE mutation
@upsert            # INSERT OR UPDATE
@hasMany           # Relation HasMany
@belongsTo         # Relation BelongsTo
@eq                # WHERE column = value
@where             # WHERE personnalisé
@orderBy           # ORDER BY dynamique
@rules             # Validation
@hash              # Hash un champ (password)
@trim              # Trim une string
@spread            # Spread un input
@middleware        # Middleware (auth:api)
@guard             # Guard
@can               # Policy
@softDeletes       # Soft deletes

Validation

input CreatePostInput {
    title: String! @rules(apply: ["required", "min:5", "max:255"])
    body: String! @rules(apply: ["required", "min:10"])
    user_id: ID! @rules(apply: ["exists:users,id"])
}

Pagination GraphQL

# Connection-based pagination (Relay spec)
query {
    posts(first: 10, page: 1) {
        data {
            id
            title
        }
        paginatorInfo {
            total
            currentPage
            lastPage
            hasMorePages
        }
    }
}

08.5 Rate Limiting

Laravel Rate Limiter

<?php

use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\RateLimiter;

// Définir un limiteur
RateLimiter::for('api', fn(Request $request) => 
    Limit::perMinute(60)->by($request->user()?->id ?: $request->ip())
);

RateLimiter::for('login', fn(Request $request) => 
    Limit::perMinute(5)->by($request->input('email') . '|' . $request->ip())
);

RateLimiter::for('uploads', fn(Request $request) => 
    Limit::perHour(10)->by($request->user()->id)
);

// Appliquer sur les routes
Route::middleware('throttle:api')->group(function () {
    Route::get('/users', [UserController::class, 'index']);
});

Route::middleware('throttle:login')->post('/login', [AuthController::class, 'login']);

// Vérifier manuellement
if (RateLimiter::tooManyAttempts('send-message:' . $user->id, 5)) {
    $seconds = RateLimiter::availableIn('send-message:' . $user->id);
    return response()->json([
        'message' => "Trop de tentatives. Réessayez dans {$seconds} secondes.",
    ], 429);
}

RateLimiter::hit('send-message:' . $user->id, 60);

API Platform Rate Limiting

# config/packages/api_platform.yaml
api_platform:
    defaults:
        extra_properties:
            rfc_7807_compliant_errors: true
    # Rate limiting via Symfony

Headers de rate limiting

X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 1623456789
Retry-After: 120

Exercices

  1. Concevez une API REST complète pour un blog (resources, pagination, filtres)
  2. Implémentez Sanctum avec token abilities
  3. Créez une ressource API Platform avec filtres
  4. Écrivez un schema GraphQL avec mutations et validation
  5. Mettez en place du rate limiting avec retry-after

Références

  • laravel.com/docs/sanctum
  • api-platform.com/docs
  • lighthouse-php.com
  • restfulapi.net
  • graphql.org/learn