🚀 Budowanie solidnych REST API w Laravel 12: kompletny przewodnik

W dzisiejszym połączonym świecie REST API stanowią kręgosłup nowoczesnych aplikacji webowych. Laravel 12 oferuje potężne narzędzia i konwencje do tworzenia skalowalnych, łatwych w utrzymaniu i bezpiecznych REST API. Ten kompleksowy przewodnik przeprowadzi Cię przez najlepsze praktyki budowania profesjonalnych API przy użyciu najnowszych możliwości Laravela.

📋 Spis treści

Dlaczego Laravel do REST API?

Laravel 12 oferuje wyjątkowe funkcje przydatne w tworzeniu API:

  1. Wbudowane API Resources: spójna transformacja danych w całym API
  2. Laravel Sanctum: lekkie uwierzytelnianie dla SPA i aplikacji mobilnych
  3. Form Request Validation: czysta, wielokrotnego użytku logika walidacji
  4. Wersjonowanie API: wbudowane wsparcie dla wersjonowania API
  5. Eloquent ORM: potężne operacje na bazie z relacjami
  6. Stos middleware: elastyczne przetwarzanie żądań i odpowiedzi
  7. Framework testów: kompleksowe narzędzia testowe w pakiecie

Struktura projektu i organizacja

Dobrze zorganizowana struktura projektu jest kluczowa dla utrzymywalnego API. Oto rekomendowany układ dla projektu API w Laravelu:

app/
├── Http/
│   ├── Controllers/
│   │   └── Api/
│   │       ├── ApiController.php          # Bazowy kontroler API
│   │       └── v1/                        # Kontrolery specyficzne dla wersji
│   │           ├── ProductController.php
│   │           ├── Auth/
│   │           │   ├── CurrentUserController.php
│   │           │   └── SocialiteLoginController.php
│   │           └── Admin/
│   │               └── ProductController.php
│   ├── Resources/                         # Transformatory zasobów API
│   │   ├── ProductResource.php
│   │   └── UserResource.php
│   ├── Requests/                          # Walidacja Form Request
│   │   ├── ProductStoreRequest.php
│   │   └── ProductUpdateRequest.php
│   └── Middleware/                        # Własne middleware
├── Traits/
│   └── ApiResponses.php                  # Ustandaryzowane odpowiedzi API
└── Models/
    ├── Product.php
    └── User.php

Strategia wersjonowania API

Wersjonowanie API jest niezbędne do utrzymania kompatybilności wstecznej podczas rozwoju API. Laravel 12 ułatwia to dzięki konfiguracji apiPrefix.

Konfiguracja bootstrap

// bootstrap/app.php
return Application::configure(basePath: dirname(__DIR__))
    ->withRouting(
        web: __DIR__ . '/../routes/web.php',
        api: __DIR__ . '/../routes/api.php',
        commands: __DIR__ . '/../routes/console.php',
        channels: __DIR__ . '/../routes/channels.php',
        health: '/up',
        apiPrefix: 'api/v1',
    )
    ->withMiddleware(function (Middleware $middleware): void {
        $middleware->statefulApi();
        $middleware->api(prepend: [
            ThrottleRequests::class . ':api',
            SubstituteBindings::class,
        ]);

        $middleware->alias([
            'throttle' => ThrottleRequests::class,
            'throttle.login' => ThrottleRequests::class,
            'verified' => App\Http\Middleware\EnsureEmailIsVerified::class,
            'admin' => App\Http\Middleware\AdminMiddleware::class,
        ]);
    ->create();

Organizacja tras

// routes/api.php
Route::prefix('v1')->group(function () {
    // Trasy publiczne
    Route::get('/products', [ProductController::class, 'index']);
    Route::get('/products/{product}', [ProductController::class, 'show']);
    
    // Trasy wymagające uwierzytelnienia
    Route::middleware(['auth:sanctum'])->group(function () {
        // Trasy administratorskie
        Route::middleware(['admin'])->prefix('admin')->group(function () {
            Route::apiResource('/products', Admin\ProductController::class);
        });
    });
});

Uwierzytelnianie z Laravel Sanctum

Laravel Sanctum dostarcza prosty, lekki system uwierzytelniania dla SPA i aplikacji mobilnych.

Konfiguracja

// config/sanctum.php
return [
    'stateful' => explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf(
        '%s%s',
        'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1',
        Sanctum::currentApplicationUrlWithPort()
    ))),
    
    'guard' => ['web'],
    'expiration' => null, // Tokeny nie wygasają
    'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''),
    
    'middleware' => [
        'authenticate_session' => Laravel\Sanctum\Http\Middleware\AuthenticateSession::class,
        'encrypt_cookies' => Illuminate\Cookie\Middleware\EncryptCookies::class,
        'validate_csrf_token' => Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class,
    ],
];

Konfiguracja modelu użytkownika

// app/Domain/User/Models/User.php
<?php

namespace App\Domain\User\Models;

use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, Notifiable;

    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    protected $hidden = [
        'password',
        'remember_token',
    ];

    protected function casts(): array
    {
        return [
            'email_verified_at' => 'datetime',
            'password' => 'hashed',
        ];
    }
}

Trasy uwierzytelniania

// routes/api.php
Route::get('/sanctum/csrf-cookie', [CsrfCookieController::class, 'show']);
Route::get('/user', fn (Request $request) => $request->user())->middleware('auth:sanctum');

// Endpointy uwierzytelniania
Route::post('/login', [AuthController::class, 'login']);
Route::post('/logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
Route::post('/register', [AuthController::class, 'register']);

// Uwierzytelnianie społecznościowe
Route::prefix('auth/{provider}')->group(function () {
    Route::get('/url', [SocialiteLoginController::class, 'redirectToProvider']);
    Route::get('/callback', [SocialiteLoginController::class, 'handleProviderCallback']);
});

Zasoby API i transformacja danych

Zasoby API zapewniają czysty i spójny sposób transformowania modeli do odpowiedzi JSON.

Bazowy kontroler API

// app/Http/Controllers/Api/ApiController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;

class ApiController extends Controller
{
    use ApiResponses;

Ustandaryzowany trait odpowiedzi

// app/Traits/ApiResponses.php
<?php

declare(strict_types=1);

namespace App\Traits;

use Illuminate\Http\JsonResponse;

trait ApiResponses
{
    protected function ok($message, $data = [], $statusCode = 200): JsonResponse
    {
        return $this->success($message, $data, $statusCode);
    }

    protected function success($message, $data = [], $statusCode = 200): JsonResponse
    {
        return response()->json([
            'data' => $data,
            'message' => $message,
            'status' => $statusCode,
        ], $statusCode);
    }

    protected function error($errors = [], $statusCode = null): JsonResponse
    {
        if (is_string($errors)) {
            return response()->json([
                'message' => $errors,
                'status' => $statusCode,
            ], $statusCode);
        }

        return response()->json([
            'errors' => $errors,
        ]);
    }
}

Przykład zasobu API

// app/Http/Resources/ProductResource.php
<?php

declare(strict_types=1);

namespace App\Http\Resources;

use App\Models\Product;
use App\Services\ProductService;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

/** @mixin Product */
class ProductResource extends JsonResource
{
    /**
     * Przekształć zasób do tablicy.
     */
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'brand_id' => $this->brand_id,
            'category_id' => $this->category_id,
            'collection_id' => $this->collection_id,
            
            // Warunkowe ładowanie relacji
            'brand' => $this->whenLoaded('brand', fn () => $this->brand),
            'category' => $this->whenLoaded('category', fn () => $this->category),
            'collection' => $this->whenLoaded('collection', fn () => $this->collection),
            
            // Szczegóły produktu
            'name' => $this->name,
            'model' => $this->model,
            'description' => $this->description,
            'short_description' => $this->short_description,
            
            // Ceny
            'price' => $this->price,
            'formatted_price' => $this->whenHas('price', fn () => $this->formatted_price),
            
            // Media
            'url' => $this->url,
            'images' => $this->images,
            'main_image' => $this->main_image,
            
            // Relacje z warunkowym ładowaniem
            'sizes' => $this->whenLoaded('sizes', fn () => SizeResource::collection($this->sizes)) ?? [],
            'attributes' => $this->whenLoaded('attributes', fn () => 
                app(ProductService::class)->transformAttributes($this->attributes)
            ),
            
            // Dane specyficzne dla użytkownika
            'bookmark_id' => $this->when($request->user(), function () use ($request) {
                return $this->bookmark()
                    ->whereUserId($request->user()->id)
                    ->pluck('id')->first() ?? false;
            }),
            
            'review' => $this->when($request->user(), fn () => 
                $this->review()->whereUserId($request->user()->id)->first() ?? false
            ),
            
            // Dane zagregowane
            'reviews_count' => $this->whenCounted('reviews', fn () => $this->reviews_count),
            'reviews_avg_rating' => $this->hasAttribute('reviews_avg_rating') && $this->reviews_avg_rating
                ? round((float) $this->reviews_avg_rating, 2)
                : 0,
        ];
    }
}

Walidacja żądań i Form Requests

Form Requests w Laravelu zapewniają czysty sposób obsługi logiki walidacji.

Product Store Request

// app/Http/Requests/ProductStoreRequest.php
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class ProductStoreRequest extends FormRequest
{
    /**
     * Określ, czy użytkownik jest uprawniony do wykonania tego żądania.
     */
    public function authorize(): bool
    {
        return $this->user()?->hasRole('admin') ?? false;
    }

    /**
     * Zwróć reguły walidacji dla żądania.
     */
    public function rules(): array
    {
        return [
            'name' => ['required', 'string', 'max:255'],
            'model' => ['required', 'string', 'max:255'],
            'description' => ['required', 'string'],
            'short_description' => ['required', 'string', 'max:500'],
            'price' => ['required', 'numeric', 'min:0'],
            'brand_id' => ['required', 'exists:brands,id'],
            'category_id' => ['required', 'exists:categories,id'],
            'collection_id' => ['nullable', 'exists:collections,id'],
            'images' => ['array'],
            'images.*' => ['image', 'mimes:jpeg,png,jpg,webp', 'max:2048'],
            'attributes' => ['array'],
            'attributes.*.attribute_id' => ['required', 'exists:attributes,id'],
            'attributes.*.value' => ['required', 'string'],
        ];
    }

    /**
     * Własne komunikaty błędów walidacji.
     */
    public function messages(): array
    {
        return [
            'name.required' => 'Nazwa produktu jest wymagana.',
            'price.required' => 'Cena produktu jest wymagana.',
            'price.min' => 'Cena produktu musi być co najmniej 0.',
            'images.*.max' => 'Rozmiar obrazu nie może przekraczać 2MB.',
        ];
    }

    /**
     * Przygotuj dane do walidacji.
     */
    protected function prepareForValidation(): void
    {
        $this->merge([
            'price' => $this->price * 100, // Konwersja na grosze
        ]);
    }
}

Zaawansowane filtrowanie ze Spatie Query Builder

Pakiet Spatie Query Builder zapewnia potężne możliwości filtrowania, sortowania i wyszukiwania w Twoim API.

Instalacja

composer require spatie/laravel-query-builder

Podstawowa implementacja

// app/Http/Controllers/Api/v1/ProductController.php
<?php

declare(strict_types=1);

namespace App\Http\Controllers\Api\v1;

use App\Http\Controllers\Api\ApiController;
use App\Http\Resources\ProductResource;
use App\Models\Product;
use Illuminate\Http\Request;
use Spatie\QueryBuilder\QueryBuilder;
use Spatie\QueryBuilder\AllowedFilter;
use Spatie\QueryBuilder\AllowedSort;

class ProductController extends ApiController
{
    public function index(Request $request)
    {
        $products = QueryBuilder::for(Product::class)
            ->allowedFilters([
                AllowedFilter::exact('brand_id'),
                AllowedFilter::exact('category_id'),
                AllowedFilter::exact('collection_id'),
                AllowedFilter::partial('name'),
                AllowedFilter::scope('price_range'),
                AllowedFilter::scope('in_stock'),
            ])
            ->allowedSorts([
                'name',
                'price',
                'created_at',
                AllowedSort::field('popularity', 'views_count'),
            ])
            ->allowedIncludes([
                'brand',
                'category',
                'collection',
                'reviews',
                'attributes',
            ])
            ->defaultSort('-created_at')
            ->paginate($request->get('per_page', 15));

        return ProductResource::collection($products);
    }

    public function show(Product $product)
    {
        $product->load([
            'brand',
            'category',
            'collection',
            'attributes.attribute',
            'reviews.user',
        ]);

        return new ProductResource($product);
    }
}

Własne filtry

// app/Models/Product.php
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Spatie\QueryBuilder\AllowedFilter;

class Product extends Model
{
    protected $fillable = [
        'name',
        'model',
        'description',
        'short_description',
        'price',
        'brand_id',
        'category_id',
        'collection_id',
    ];

    protected $casts = [
        'price' => 'integer',
        'images' => 'array',
    ];

    public function brand(): BelongsTo
    {
        return $this->belongsTo(Brand::class);
    }

    public function category(): BelongsTo
    {
        return $this->belongsTo(Category::class);
    }

    public function attributes(): HasMany
    {
        return $this->hasMany(ProductAttribute::class);
    }

    /**
     * Zakres do filtrowania po przedziale cenowym
     */
    public function scopePriceRange($query, $min, $max)
    {
        return $query->whereBetween('price', [$min * 100, $max * 100]);
    }
}

Przykłady użycia

# Podstawowe filtrowanie
GET /api/v1/products?filter[brand_id]=1&filter[category_id]=2

# Wyszukiwanie tekstowe
GET /api/v1/products?filter[name]=laptop

# Filtrowanie po zakresie cenowym
GET /api/v1/products?filter[price_range]=100,500

# Sortowanie
GET /api/v1/products?sort=price&sort=-created_at

# Dołączanie relacji
GET /api/v1/products?include=brand,category,reviews

# Łączenie filtrów
GET /api/v1/products?filter[brand_id]=1&filter[price_range]=100,500&sort=-price&include=brand

Limitowanie zapytań i bezpieczeństwo

Własne ograniczanie liczby zapytań

// routes/api.php
Route::middleware(['throttle:login'])->group(function () {
    Route::post('/login', [AuthController::class, 'login']);
    Route::post('/register', [AuthController::class, 'register']);
});

Route::middleware(['auth:sanctum', 'throttle:60,1'])->group(function () {
    Route::apiResource('products', ProductController::class);
});

Testowanie API

Kompleksowe testy zapewniają poprawne działanie API i jego stabilność w czasie.

Przykład testu funkcjonalnego

// tests/Feature/ProductApiTest.php
<?php

namespace Tests\Feature;

use App\Models\Product;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

class ProductApiTest extends TestCase
{
    use RefreshDatabase;

    public function test_can_list_products()
    {
        Product::factory()->count(3)->create();

        $response = $this->getJson('/api/v1/products');

        $response->assertStatus(200)
            ->assertJsonStructure([
                'data' => [
                    '*' => [
                        'id',
                        'name',
                        'price',
                        'brand',
                        'category',
                    ]
                ]
            ]);
    }

    public function test_can_filter_products_by_brand()
    {
        $brand1 = Brand::factory()->create();
        $brand2 = Brand::factory()->create();

        Product::factory()->create(['brand_id' => $brand1->id]);
        Product::factory()->create(['brand_id' => $brand2->id]);

        $response = $this->getJson("/api/v1/products?filter[brand_id]={$brand1->id}");

        $response->assertStatus(200);
        $response->assertJsonCount(1, 'data');
    }

    public function test_authenticated_user_can_create_product()
    {
        $user = User::factory()->create();
        $user->assignRole('admin');
        Sanctum::actingAs($user);

        $productData = [
            'name' => 'Test Product',
            'model' => 'TP-001',
            'description' => 'Test description',
            'short_description' => 'Short description',
            'price' => 99.99,
            'brand_id' => Brand::factory()->create()->id,
            'category_id' => Category::factory()->create()->id,
        ];

        $response = $this->postJson('/api/v1/products', $productData);

        $response->assertStatus(201)
            ->assertJsonFragment(['name' => 'Test Product']);

        $this->assertDatabaseHas('products', [
            'name' => 'Test Product',
            'price' => 9999, // Cena w groszach
        ]);
    }

    public function test_unauthenticated_user_cannot_create_product()
    {
        $productData = [
            'name' => 'Test Product',
            'model' => 'TP-001',
            'description' => 'Test description',
            'price' => 99.99,
        ];

        $response = $this->postJson('/api/v1/products', $productData);

        $response->assertStatus(401);
    }
}

Zakończenie

Budowanie solidnych REST API w Laravel 12 wymaga dbałości o szczegóły, odpowiedniej architektury i trzymania się najlepszych praktyk. Wdrażając strategie opisane w tym przewodniku, stworzysz API, które są:

  • Skalowalne: odpowiednie wersjonowanie i organizacja wspierają rozwój
  • Bezpieczne: uwierzytelnianie, walidacja i limitowanie zapytań chronią Twoje API
  • Utrzymywalne: czysta struktura kodu i kompleksowe testy
  • Przyjazne dla użytkownika: spójne odpowiedzi i klarowna obsługa błędów
  • Wydajne: efektywne zapytania i odpowiednie strategie cache

Pamiętaj, aby:

  • Zawsze walidować dane wejściowe
  • Używać API Resources do spójnej transformacji danych
  • Wdrożyć właściwe uwierzytelnianie z Laravel Sanctum
  • Wersjonować API od samego początku
  • Pisać kompleksowe testy
  • Dokumentować endpointy API
  • Monitorować wydajność i użycie

Połączenie możliwości Laravela i tych najlepszych praktyk pomoże Ci budować profesjonalne REST API, które będą niezawodne i efektywne.


Obserwuj mnie na LinkedIn po więcej wskazówek o Laravelu!

Chcesz dowiedzieć się więcej o Laravelu, tworzeniu API lub konkretnych implementacjach? Daj znać w komentarzach poniżej!

Komentarze (0)
Zostaw komentarz

© 2026 Wszelkie prawa zastrzeżone.