Глава 12.4: REST API + SPA — отделение frontend от backend, CORS, токены
🎯 Что ты узнаешь
- Как работает архитектура с разделенным frontend и backend
- Что такое REST API и как его правильно проектировать
- Как решить проблему CORS при работе с разными доменами
- Системы аутентификации для API: токены, JWT, Sanctum
- Практика: создание полноценного API для SPA-приложения
📖 Теория
Почему разделять frontend и backend?
Традиционный подход (монолит):
Browser → Laravel → Blade → HTML → Browser
↓
DatabaseСовременный подход (SPA + API):
Browser → Vue/React (Frontend)
↓ HTTP/JSON
Laravel API (Backend)
↓
DatabaseПреимущества разделения:
- Независимая разработка — frontend и backend команды работают параллельно
- Множественные клиенты — один API для web, mobile, desktop приложений
- Лучшая производительность — меньше нагрузки на сервер, кеширование на клиенте
- Современный UX — мгновенные переходы без перезагрузки страницы
- Масштабируемость — можно разместить frontend и backend на разных серверах
🏗️ Что такое REST API
REST (Representational State Transfer) — архитектурный стиль для создания web-сервисов.
Принципы REST
- Stateless — каждый запрос содержит всю необходимую информацию
- Клиент-сервер — разделение ответственности
- Единый интерфейс — стандартные HTTP методы
- Ресурсо-ориентированность — работа с сущностями (users, posts, comments)
HTTP методы в REST
| Метод | Операция | Пример | Идемпотентность |
|---|---|---|---|
| GET | Получение | GET /api/posts | Да |
| POST | Создание | POST /api/posts | Нет |
| PUT | Полное обновление | PUT /api/posts/1 | Да |
| PATCH | Частичное обновление | PATCH /api/posts/1 | Нет |
| DELETE | Удаление | DELETE /api/posts/1 | Да |
Правильное именование эндпоинтов
✅ Правильно:
GET /api/posts # Список постов
GET /api/posts/1 # Один пост
POST /api/posts # Создать пост
PUT /api/posts/1 # Обновить пост полностью
PATCH /api/posts/1 # Обновить пост частично
DELETE /api/posts/1 # Удалить пост
GET /api/posts/1/comments # Комментарии к посту
POST /api/posts/1/comments # Добавить комментарий❌ Неправильно:
GET /api/getPosts
POST /api/createPost
GET /api/post/delete/1
GET /api/posts?action=delete&id=1Коды ответов HTTP
2xx — Успех:
200 OK— успешный запрос201 Created— ресурс создан204 No Content— успешно, но нет содержимого (для DELETE)
4xx — Ошибка клиента:
400 Bad Request— неверные данные401 Unauthorized— требуется аутентификация403 Forbidden— доступ запрещен404 Not Found— ресурс не найден422 Unprocessable Entity— ошибки валидации
5xx — Ошибка сервера:
500 Internal Server Error— что-то сломалось на сервере
🚀 Создание REST API в Laravel
Структура routes/api.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\PostController;
use App\Http\Controllers\Api\AuthController;
// Публичные маршруты
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
// Защищенные маршруты (требуют токен)
Route::middleware('auth:sanctum')->group(function () {
Route::post('/logout', [AuthController::class, 'logout']);
Route::get('/user', [AuthController::class, 'user']);
// Resource маршруты для постов
Route::apiResource('posts', PostController::class);
// Дополнительные действия
Route::post('/posts/{post}/like', [PostController::class, 'like']);
Route::post('/posts/{post}/publish', [PostController::class, 'publish']);
});Важно: Маршруты в api.php автоматически получают префикс /api.
API Resource контроллер
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Post;
use App\Http\Resources\PostResource;
use App\Http\Requests\StorePostRequest;
use App\Http\Requests\UpdatePostRequest;
use Illuminate\Http\JsonResponse;
class PostController extends Controller
{
/**
* Список всех постов
*/
public function index(): JsonResponse
{
$posts = Post::with('user')
->latest()
->paginate(15);
return response()->json([
'success' => true,
'data' => PostResource::collection($posts),
'meta' => [
'current_page' => $posts->currentPage(),
'total' => $posts->total(),
'per_page' => $posts->perPage(),
]
]);
}
/**
* Создание нового поста
*/
public function store(StorePostRequest $request): JsonResponse
{
$post = $request->user()->posts()->create(
$request->validated()
);
return response()->json([
'success' => true,
'message' => 'Post created successfully',
'data' => new PostResource($post)
], 201); // 201 Created
}
/**
* Получение одного поста
*/
public function show(Post $post): JsonResponse
{
$post->load(['user', 'comments.user']);
return response()->json([
'success' => true,
'data' => new PostResource($post)
]);
}
/**
* Обновление поста
*/
public function update(UpdatePostRequest $request, Post $post): JsonResponse
{
// Проверка прав доступа
if ($post->user_id !== $request->user()->id) {
return response()->json([
'success' => false,
'message' => 'You are not authorized to update this post'
], 403);
}
$post->update($request->validated());
return response()->json([
'success' => true,
'message' => 'Post updated successfully',
'data' => new PostResource($post)
]);
}
/**
* Удаление поста
*/
public function destroy(Post $post): JsonResponse
{
if ($post->user_id !== auth()->id()) {
return response()->json([
'success' => false,
'message' => 'You are not authorized to delete this post'
], 403);
}
$post->delete();
return response()->json([
'success' => true,
'message' => 'Post deleted successfully'
], 204); // 204 No Content
}
/**
* Дополнительное действие
*/
public function like(Post $post): JsonResponse
{
$user = auth()->user();
if ($post->likes()->where('user_id', $user->id)->exists()) {
$post->likes()->detach($user->id);
$liked = false;
} else {
$post->likes()->attach($user->id);
$liked = true;
}
return response()->json([
'success' => true,
'liked' => $liked,
'likes_count' => $post->likes()->count()
]);
}
}API Resources — форматирование ответов
app/Http/Resources/PostResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class PostResource extends JsonResource
{
/**
* Transform the resource into an array.
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'slug' => $this->slug,
'excerpt' => $this->excerpt,
'content' => $this->content,
'published_at' => $this->published_at?->toIso8601String(),
'created_at' => $this->created_at->toIso8601String(),
'updated_at' => $this->updated_at->toIso8601String(),
// Вложенные ресурсы
'author' => new UserResource($this->whenLoaded('user')),
'comments' => CommentResource::collection($this->whenLoaded('comments')),
// Вычисляемые поля
'is_published' => $this->isPublished(),
'reading_time' => $this->estimatedReadingTime(),
// Условные поля
'views_count' => $this->when(
$request->user()?->isAdmin(),
$this->views_count
),
// Ссылки
'links' => [
'self' => route('api.posts.show', $this->id),
'author' => route('api.users.show', $this->user_id),
]
];
}
}app/Http/Resources/UserResource.php:
<?php
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'name' => $this->name,
'email' => $this->when(
$request->user()?->id === $this->id,
$this->email
),
'avatar_url' => $this->avatar_url,
'bio' => $this->bio,
'created_at' => $this->created_at->toIso8601String(),
];
}
}Валидация для API
app/Http/Requests/StorePostRequest.php:
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Http\Exceptions\HttpResponseException;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return true; // или проверка прав
}
public function rules(): array
{
return [
'title' => 'required|string|max:255',
'content' => 'required|string|min:100',
'excerpt' => 'nullable|string|max:500',
'tags' => 'nullable|array',
'tags.*' => 'exists:tags,id',
'published_at' => 'nullable|date',
];
}
public function messages(): array
{
return [
'title.required' => 'Post title is required',
'content.min' => 'Post content must be at least 100 characters',
];
}
/**
* Переопределяем обработку ошибок для API
*/
protected function failedValidation(Validator $validator)
{
throw new HttpResponseException(
response()->json([
'success' => false,
'message' => 'Validation errors',
'errors' => $validator->errors()
], 422)
);
}
}🔒 CORS (Cross-Origin Resource Sharing)
Что такое CORS и зачем он нужен
Проблема:
Frontend: http://localhost:5173 (Vue/React dev server)
Backend: http://localhost:8000 (Laravel API)
❌ Browser: "Blocked by CORS policy"Same-Origin Policy — браузерная защита, запрещающая JavaScript делать запросы на другие домены.
CORS — механизм, позволяющий серверу разрешить запросы с других доменов.
Настройка CORS в Laravel
config/cors.php:
<?php
return [
/*
* Пути, к которым применяется CORS
*/
'paths' => ['api/*', 'sanctum/csrf-cookie'],
/*
* Разрешенные HTTP методы
*/
'allowed_methods' => ['*'], // или ['GET', 'POST', 'PUT', 'DELETE']
/*
* Разрешенные источники (origins)
*/
'allowed_origins' => [
'http://localhost:5173', // Vite dev server
'http://localhost:3000', // React dev server
'https://myapp.com', // Production frontend
],
// Или разрешить все (НЕ для production!)
// 'allowed_origins' => ['*'],
/*
* Паттерны разрешенных origins
*/
'allowed_origins_patterns' => [
'/^https:\/\/.*\.myapp\.com$/', // все поддомены
],
/*
* Разрешенные заголовки
*/
'allowed_headers' => ['*'],
/*
* Заголовки, доступные JavaScript
*/
'exposed_headers' => [],
/*
* Максимальное время кеширования preflight запроса
*/
'max_age' => 0,
/*
* Разрешить отправку cookies и авторизационных заголовков
*/
'supports_credentials' => true,
];Важно: Middleware HandleCors должен быть в app/Http/Kernel.php:
protected $middleware = [
// ...
\Illuminate\Http\Middleware\HandleCors::class,
// ...
];Как работает CORS
Simple Request (простой запрос):
GET /api/posts HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
↓ Response ↓
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Credentials: truePreflight Request (предварительный запрос):
Браузер сначала отправляет OPTIONS запрос:
OPTIONS /api/posts HTTP/1.1
Host: api.myapp.com
Origin: https://myapp.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
↓ Response ↓
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://myapp.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Max-Age: 86400Если ответ положительный, браузер отправляет основной запрос.
🎫 Аутентификация для API
Почему не Sessions для API?
Sessions хранят состояние на сервере и используют cookies. Это плохо для API:
- Stateful — нарушает принцип REST
- CSRF — уязвимость при кросс-доменных запросах
- Масштабирование — сложно при нескольких серверах
- Mobile apps — нет cookies
Решение: Token-based аутентификация
1. User логинится → получает токен
2. Каждый запрос → отправляет токен в заголовке
3. Сервер проверяет токен → возвращает данные🔐 Laravel Sanctum
Sanctum — официальный пакет Laravel для API аутентификации.
Установка Sanctum
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrateapp/Http/Kernel.php:
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],User модель:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}AuthController для API
app/Http/Controllers/Api/AuthController.php:
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
/**
* Регистрация нового пользователя
*/
public function register(Request $request)
{
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users',
'password' => 'required|string|min:8|confirmed',
]);
$user = User::create([
'name' => $validated['name'],
'email' => $validated['email'],
'password' => Hash::make($validated['password']),
]);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'User registered successfully',
'data' => [
'user' => $user,
'token' => $token,
]
], 201);
}
/**
* Вход пользователя
*/
public function login(Request $request)
{
$request->validate([
'email' => 'required|email',
'password' => 'required',
]);
$user = User::where('email', $request->email)->first();
if (!$user || !Hash::check($request->password, $user->password)) {
throw ValidationException::withMessages([
'email' => ['The provided credentials are incorrect.'],
]);
}
// Удаляем старые токены (опционально)
$user->tokens()->delete();
// Создаем новый токен
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'success' => true,
'message' => 'Login successful',
'data' => [
'user' => $user,
'token' => $token,
]
]);
}
/**
* Выход пользователя
*/
public function logout(Request $request)
{
// Удаляем текущий токен
$request->user()->currentAccessToken()->delete();
return response()->json([
'success' => true,
'message' => 'Logged out successfully'
]);
}
/**
* Получение текущего пользователя
*/
public function user(Request $request)
{
return response()->json([
'success' => true,
'data' => $request->user()
]);
}
}Использование токенов на клиенте
JavaScript (Fetch API):
// Сохраняем токен после логина
const loginResponse = await fetch('http://localhost:8000/api/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({
email: 'user@example.com',
password: 'password'
})
});
const { data } = await loginResponse.json();
localStorage.setItem('auth_token', data.token);
// Используем токен в запросах
const response = await fetch('http://localhost:8000/api/posts', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('auth_token')}`,
'Accept': 'application/json',
}
});Axios (более удобно):
import axios from 'axios';
// Настройка axios
axios.defaults.baseURL = 'http://localhost:8000/api';
axios.defaults.headers.common['Accept'] = 'application/json';
// Добавляем токен ко всем запросам
const token = localStorage.getItem('auth_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
// Использование
try {
const { data } = await axios.get('/posts');
console.log(data);
} catch (error) {
if (error.response.status === 401) {
// Токен невалиден, перенаправляем на логин
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
}🔑 JWT (JSON Web Tokens)
Что такое JWT
JWT — самодостаточный токен, содержащий всю информацию о пользователе.
Структура JWT:
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cДекодированный JWT:
// Header
{
"alg": "HS256",
"typ": "JWT"
}
// Payload
{
"sub": "1234567890",
"name": "John Doe",
"email": "john@example.com",
"exp": 1716239022
}
// Signature (проверка подлинности)Sanctum vs JWT
| Feature | Sanctum | JWT |
|---|---|---|
| Хранение | База данных | Stateless |
| Отзыв токена | Легко (удалить из БД) | Сложно (blacklist) |
| Производительность | Запрос к БД | Без БД |
| Размер токена | Короткий | Длинный |
| Подходит для | SPA, Mobile | Микросервисы |
Рекомендация: Для большинства Laravel проектов используй Sanctum — проще и безопаснее.
Использование JWT (пакет tymon/jwt-auth)
composer require tymon/jwt-auth
php artisan vendor:publish --provider="Tymon\JWTAuth\Providers\LaravelServiceProvider"
php artisan jwt:secretconfig/auth.php:
'guards' => [
'api' => [
'driver' => 'jwt',
'provider' => 'users',
],
],User модель:
use Tymon\JWTAuth\Contracts\JWTSubject;
class User extends Authenticatable implements JWTSubject
{
public function getJWTIdentifier()
{
return $this->getKey();
}
public function getJWTCustomClaims()
{
return [];
}
}Контроллер:
use Tymon\JWTAuth\Facades\JWTAuth;
public function login(Request $request)
{
$credentials = $request->only('email', 'password');
if (!$token = JWTAuth::attempt($credentials)) {
return response()->json(['error' => 'Unauthorized'], 401);
}
return response()->json([
'token' => $token,
'type' => 'bearer',
'expires_in' => auth()->factory()->getTTL() * 60
]);
}🎨 Практика: Полноценный API для блога
Структура проекта
app/
├── Http/
│ ├── Controllers/
│ │ └── Api/
│ │ ├── AuthController.php
│ │ ├── PostController.php
│ │ ├── CommentController.php
│ │ └── UserController.php
│ ├── Resources/
│ │ ├── PostResource.php
│ │ ├── CommentResource.php
│ │ └── UserResource.php
│ ├── Requests/
│ │ ├── StorePostRequest.php
│ │ ├── UpdatePostRequest.php
│ │ └── StoreCommentRequest.php
│ └── Middleware/
│ └── EnsureUserOwnsPost.php
└── Models/
├── User.php
├── Post.php
└── Comment.phproutes/api.php
<?php
use Illuminate\Support\Facades\Route;
use App\Http\Controllers\Api\{
AuthController,
PostController,
CommentController,
UserController
};
// Публичные маршруты
Route::prefix('auth')->group(function () {
Route::post('/register', [AuthController::class, 'register']);
Route::post('/login', [AuthController::class, 'login']);
});
// Публичное чтение
Route::get('/posts', [PostController::class, 'index']);
Route::get('/posts/{post}', [PostController::class, 'show']);
Route::get('/users/{user}', [UserController::class, 'show']);
// Защищенные маршруты
Route::middleware('auth:sanctum')->group(function () {
// Аутентификация
Route::post('/auth/logout', [AuthController::class, 'logout']);
Route::get('/auth/user', [AuthController::class, 'user']);
// Посты (только создание, обновление, удаление)
Route::post('/posts', [PostController::class, 'store']);
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
// Дополнительные действия с постами
Route::post('/posts/{post}/like', [PostController::class, 'like']);
Route::post('/posts/{post}/publish', [PostController::class, 'publish']);
// Комментарии
Route::post('/posts/{post}/comments', [CommentController::class, 'store']);
Route::put('/comments/{comment}', [CommentController::class, 'update']);
Route::delete('/comments/{comment}', [CommentController::class, 'destroy']);
// Профиль пользователя
Route::put('/profile', [UserController::class, 'update']);
Route::post('/profile/avatar', [UserController::class, 'uploadAvatar']);
});Обработка ошибок
app/Exceptions/Handler.php:
public function register(): void
{
$this->renderable(function (NotFoundHttpException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'success' => false,
'message' => 'Resource not found'
], 404);
}
});
$this->renderable(function (AuthenticationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'success' => false,
'message' => 'Unauthenticated'
], 401);
}
});
$this->renderable(function (ValidationException $e, Request $request) {
if ($request->is('api/*')) {
return response()->json([
'success' => false,
'message' => 'Validation failed',
'errors' => $e->errors()
], 422);
}
});
}Middleware для проверки владельца
app/Http/Middleware/EnsureUserOwnsPost.php:
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use App\Models\Post;
class EnsureUserOwnsPost
{
public function handle(Request $request, Closure $next)
{
$post = $request->route('post');
if ($post->user_id !== $request->user()->id) {
return response()->json([
'success' => false,
'message' => 'You are not authorized to perform this action'
], 403);
}
return $next($request);
}
}Регистрация middleware в app/Http/Kernel.php:
protected $middlewareAliases = [
// ...
'post.owner' => \App\Http\Middleware\EnsureUserOwnsPost::class,
];Использование:
Route::middleware(['auth:sanctum', 'post.owner'])->group(function () {
Route::put('/posts/{post}', [PostController::class, 'update']);
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});📱 Пример frontend (Vue 3 + Composition API)
Настройка API клиента
src/services/api.js:
import axios from 'axios';
const api = axios.create({
baseURL: 'http://localhost:8000/api',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
}
});
// Добавляем токен к каждому запросу
api.interceptors.request.use(config => {
const token = localStorage.getItem('auth_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Обрабатываем ошибки авторизации
api.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
localStorage.removeItem('auth_token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;Composable для постов
src/composables/usePosts.js:
import { ref } from 'vue';
import api from '@/services/api';
export function usePosts() {
const posts = ref([]);
const loading = ref(false);
const error = ref(null);
const fetchPosts = async () => {
loading.value = true;
error.value = null;
try {
const { data } = await api.get('/posts');
posts.value = data.data;
} catch (err) {
error.value = err.response?.data?.message || 'Failed to fetch posts';
} finally {
loading.value = false;
}
};
const createPost = async (postData) => {
try {
const { data } = await api.post('/posts', postData);
posts.value.unshift(data.data);
return data.data;
} catch (err) {
throw new Error(err.response?.data?.message || 'Failed to create post');
}
};
const deletePost = async (postId) => {
try {
await api.delete(`/posts/${postId}`);
posts.value = posts.value.filter(p => p.id !== postId);
} catch (err) {
throw new Error(err.response?.data?.message || 'Failed to delete post');
}
};
return {
posts,
loading,
error,
fetchPosts,
createPost,
deletePost
};
}Компонент списка постов
src/components/PostList.vue:
<template>
<div class="post-list">
<div v-if="loading" class="loading">Loading posts...</div>
<div v-else-if="error" class="error">{{ error }}</div>
<div v-else>
<article v-for="post in posts" :key="post.id" class="post-card">
<h2>{{ post.title }}</h2>
<p class="excerpt">{{ post.excerpt }}</p>
<div class="post-meta">
<span>By {{ post.author.name }}</span>
<span>{{ formatDate(post.created_at) }}</span>
</div>
<div class="post-actions">
<button @click="likePost(post.id)">
❤️ {{ post.likes_count }}
</button>
<button
v-if="isOwner(post)"
@click="deletePost(post.id)"
class="danger"
>
Delete
</button>
</div>
</article>
</div>
</div>
</template>
<script setup>
import { onMounted } from 'vue';
import { usePosts } from '@/composables/usePosts';
import { useAuth } from '@/composables/useAuth';
const { posts, loading, error, fetchPosts, deletePost } = usePosts();
const { user } = useAuth();
onMounted(() => {
fetchPosts();
});
const isOwner = (post) => {
return user.value && post.author.id === user.value.id;
};
const formatDate = (date) => {
return new Date(date).toLocaleDateString();
};
const likePost = async (postId) => {
try {
await api.post(`/posts/${postId}/like`);
await fetchPosts(); // Обновляем список
} catch (error) {
console.error('Failed to like post', error);
}
};
</script>
<style scoped>
.post-card {
border: 1px solid #ddd;
padding: 1.5rem;
margin-bottom: 1rem;
border-radius: 8px;
}
.post-meta {
display: flex;
gap: 1rem;
color: #666;
font-size: 0.875rem;
margin: 0.5rem 0;
}
.post-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
button.danger {
background-color: #dc3545;
color: white;
}
</style>⚠️ Важные моменты безопасности
1. Rate Limiting
app/Http/Kernel.php:
protected $middlewareGroups = [
'api' => [
'throttle:60,1', // 60 запросов в минуту
// ...
],
];Кастомный rate limit для логина:
// routes/api.php
Route::post('/login', [AuthController::class, 'login'])
->middleware('throttle:5,1'); // 5 попыток в минуту2. Валидация всегда
// ❌ Плохо
public function store(Request $request)
{
Post::create($request->all()); // Опасно!
}
// ✅ Хорошо
public function store(StorePostRequest $request)
{
Post::create($request->validated());
}3. Не возвращай чувствительные данные
// ❌ Плохо
return User::all(); // Вернет пароли, токены и т.д.
// ✅ Хорошо
return UserResource::collection(User::all());4. Используй HTTPS в production
// config/cors.php (production)
'allowed_origins' => [
'https://myapp.com', // Только HTTPS!
],
// .env
APP_URL=https://myapp.com5. Проверяй права доступа
// ✅ Всегда проверяй
public function update(Request $request, Post $post)
{
$this->authorize('update', $post); // Policy
// или
if ($post->user_id !== $request->user()->id) {
abort(403);
}
// ...
}📋 Чеклист создания API
- [ ] Установил Sanctum и настроил CORS
- [ ] Создал API Resource контроллеры
- [ ] Использую API Resources для форматирования ответов
- [ ] Все данные валидируются через Form Requests
- [ ] Настроил rate limiting
- [ ] Обрабатываю ошибки и возвращаю правильные HTTP коды
- [ ] Проверяю права доступа перед изменением/удалением
- [ ] Не возвращаю чувствительные данные
- [ ] Использую пагинацию для списков
- [ ] Документировал API (Postman, Swagger, Scribe)
- [ ] Написал тесты для критичных эндпоинтов
🎯 Практические задания
Задание 1: Создай базовый API для задач (Todo)
Требования:
- Регистрация и логин через Sanctum
- CRUD для задач (только свои задачи)
- Пагинация списка задач
- Фильтр по статусу (completed/pending)
- API Resource для форматирования
Эндпоинты:
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/tasks # Список с пагинацией
POST /api/tasks # Создать
GET /api/tasks/{task} # Одна задача
PUT /api/tasks/{task} # Обновить
DELETE /api/tasks/{task} # Удалить
PATCH /api/tasks/{task}/toggle # Переключить статусЗадание 2: Добавь CORS и подключи Vue frontend
Требования:
- Настрой CORS для
localhost:5173 - Создай Vue компонент со списком задач
- Реализуй создание, удаление, переключение статуса
- Обработай ошибки валидации на клиенте
- Покажи loading состояния
Задание 3: Система комментариев с вложенностью
Расширь API:
GET /api/posts/{post}/comments # Все комментарии
POST /api/posts/{post}/comments # Добавить комментарий
POST /api/comments/{comment}/reply # Ответить на комментарий
DELETE /api/comments/{comment} # Удалить свойОсобенности:
- Древовидная структура комментариев
- Рекурсивный API Resource для вложенных ответов
- Проверка прав (только автор может удалить)
Задание 4: Поиск и фильтрация
Добавь к эндпоинту /api/posts:
GET /api/posts?search=laravel # Поиск по заголовку/тексту
GET /api/posts?author=5 # Фильтр по автору
GET /api/posts?tag=php,javascript # Фильтр по тегам
GET /api/posts?sort=created_at&order=desc
GET /api/posts?per_page=20&page=2 # ПагинацияРеализуй:
- Query parameters обработку в контроллере
- Scope в модели для переиспользования
- Валидацию параметров
🐛 Типичные ошибки
1. Забыл добавить auth:sanctum middleware
// ❌ Любой может удалить пост
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
// ✅ Только авторизованные
Route::middleware('auth:sanctum')->group(function () {
Route::delete('/posts/{post}', [PostController::class, 'destroy']);
});2. Не проверяешь владельца ресурса
// ❌ Пользователь может удалить чужой пост
public function destroy(Post $post) {
$post->delete();
}
// ✅ Только свой
public function destroy(Post $post) {
if ($post->user_id !== auth()->id()) {
return response()->json(['message' => 'Forbidden'], 403);
}
$post->delete();
}3. Возвращаешь массив вместо JSON с meta
// ❌ Клиенту сложно обрабатывать
return Post::all();
// ✅ Структурированный ответ
return response()->json([
'success' => true,
'data' => PostResource::collection(Post::all())
]);4. Не настроил CORS
Error: Access to fetch at 'http://localhost:8000/api/posts'
from origin 'http://localhost:5173' has been blocked by CORS policyРешение: Проверь config/cors.php и добавь origin.
5. Отправляешь токен неправильно
// ❌ Плохо
headers: { 'Authorization': token }
// ✅ Правильно
headers: { 'Authorization': `Bearer ${token}` }💡 Советы профессионалов
Версионируй API: Используй
/api/v1/postsвместо/api/postsДокументируй API: Используй Laravel Scribe или Postman Collections
Используй API Resources всегда: Никогда не возвращай модели напрямую
Пиши тесты: Feature тесты для API критично важны
Логируй важные действия:
phpLog::info('User logged in', ['user_id' => $user->id]);Используй soft deletes для критичных данных:
phpuse SoftDeletes;Кешируй тяжелые запросы:
phpreturn Cache::remember('posts.all', 3600, function () { return Post::with('user')->get(); });
📚 Что дальше
Теперь ты умеешь:
- ✅ Создавать REST API по стандартам
- ✅ Настраивать CORS для кросс-доменных запросов
- ✅ Использовать Sanctum для API аутентификации
- ✅ Форматировать ответы через API Resources
- ✅ Валидировать и обрабатывать ошибки
- ✅ Подключать SPA frontend к Laravel backend
Следующая глава: Real-time и WebSockets — научишься делать live-обновления!
Полезные ресурсы:
- Laravel Sanctum Docs
- API Resource Docs
- REST API Best Practices
- Laravel Scribe — документация API
❓ Вопросы для самопроверки
- В чем разница между Sessions и Token-based аутентификацией?
- Что такое CORS и почему браузер блокирует запросы?
- Какие HTTP коды нужно возвращать для успеха, ошибок клиента и сервера?
- Почему нельзя возвращать модели напрямую, нужны API Resources?
- Как защитить эндпоинт, чтобы пользователь мог редактировать только свои ресурсы?
- Зачем нужен preflight запрос (OPTIONS) в CORS?
- В чем разница между PUT и PATCH методами?
- Как правильно хранить токен на клиенте?
Готов к real-time? Погнали в следующую главу! 🚀