Blog

🚀 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!

Wsparcie istniejącego systemu

Potrzebujesz pomocy z działającą aplikacją?

Pomagam firmom rozwijać działające systemy, porządkować wdrożenia i dodawać nowe funkcje bez dokładania chaosu do projektu.

Komentarze (0)
Zaloguj się, aby dodać komentarz

Musisz być zalogowany, aby dodać komentarz.

Zaloguj się

Potrzebujesz kogoś, kto weźmie odpowiedzialność za kolejny krok?

Porozmawiajmy o Twoim projekcie i określmy zakres, który ma sens dla Twoich celów.