API-First Laravel проекты
Я разрабатываю API на Laravel уже много лет, и одна вещь стала очевидна: способ, которым мы учим Laravel, не совпадает с тем, как мы его на самом деле используем.
Большинство учебников для начинающих по-прежнему сосредоточены на full-stack приложениях с Blade представлениями, скаффолдингом аутентификации и серверным рендерингом страниц. Это хорошо для понимания основ Laravel, но реальность современной разработки иная.
Сегодня Laravel — это бэкэнд. Он питает React-фронтенды, Vue-приложения, мобильные приложения и интеграции третьих сторон. Он говорит на JSON, а не на HTML. Понимание того, как создавать чистые, поддерживаемые API, больше не является полезным навыком — это основание.
Это руководство содержит десять прогрессивных проектов, предназначенных для обучения разработке API с Laravel. Каждый проект развивает концепции предыдущих, представляя новые шаблоны и задачи, с которыми вы столкнетесь в реальных приложениях. Я не буду приукрашивать: некоторые из них вас разочаруют. Это хорошо. Борьба с этими концепциями сейчас означает, что вы не будете их изучать под давлением сроков позже.
Почему эти проекты имеют значение
Прежде чем мы начнем, давайте поговорим о том, что делает эти проекты отличными от типичных учебников для начинающих.
Во-первых, они сосредоточены на API. Вы не будете создавать ни одного Blade представления. Каждое взаимодействие происходит через HTTP эндпоинты, возвращающие JSON. Это заставляет вас думать об управлении состоянием, аутентификации и преобразовании данных иначе, чем в традиционном веб-приложении.
Во-вторых, они прогрессивны. Проект один учит базовому CRUD. Проект десять включает вебхуки, очереди и сложное управление состоянием. Не пропускайте шаги. Шаблоны, которые вы изучаете в начале, увеличиваются в более сложных проектах.
В-третьих, они неполны по замыслу. Я дам вам структуру и ключевые концепции, но я не буду держать вас за руку через каждую строку кода. Вам нужно будет читать документацию, принимать решения и иногда переделывать, когда вы понимаете, что ваш первый подход был не идеален. Это не ошибка — так вы действительно учитесь.
Проект 1: Task API — Обучение мышлению в ресурсах
Ключевые концепции
- CRUD операции,
- API Resources,
- HTTP статус-коды,
- валидация
Давайте начнем с простого. Создайте API, который управляет задачами. Это намеренно простой проект, потому что цель — это не сложность домена, а изучение того, как Laravel обрабатывает API ответы.
Endpoints (Эндпоинты)
GET /api/tasks # Получить все задачи
POST /api/tasks # Создать новую задачу
GET /api/tasks/{id} # Показать конкретную задачу
PUT /api/tasks/{id} # Обновить задачу
DELETE /api/tasks/{id} # Удалить задачу
Стандартные REST соглашения. Здесь нет ничего удивительного. Но вот где начинающие обычно ошибаются: они возвращают модели непосредственно.
// Не делайте так
public function index()
{
return Task::all();
}
Это работает, конечно. Но это неэффективно и раскрывает структуру вашей базы данных непосредственно потребителям API. Что произойдет, если вы переименуете столбец? Что если вам нужно последовательно форматировать даты? Что насчет включения вычисленных значений?
Здесь в дело вступают API Resources. Это трансформационные слои, которые находятся между вашими моделями и вашими JSON ответами.
namespace App\Http\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TaskResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'completed' => (bool) $this->completed,
'completed_at' => $this->completed_at?->toISOString(),
'created_at' => $this->created_at->toISOString(),
'updated_at' => $this->updated_at->toISOString(),
];
}
}
Теперь ваш контроллер выглядит так:
public function index()
{
return TaskResource::collection(
Task::all()
);
}
public function show(Task $task)
{
return new TaskResource($task);
}
Видите разницу? Структура вашего API ответа теперь отделена от схемы вашей базы данных. Вы можете изменить вашу базу данных без нарушения вашего API контракта. Это разделение становится критическим по мере роста вашего приложения.
Валидация, которая действительно помогает
Form Requests — это ваш друг. Не валидируйте в контроллерах.
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreTaskRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'title' => ['required', 'string', 'max:255'],
'description' => ['nullable', 'string'],
'completed' => ['boolean'],
];
}
}
Затем используйте его в вашем контроллере:
public function store(StoreTaskRequest $request)
{
$task = Task::create($request->validated());
return new TaskResource($task);
}
Laravel автоматически возвращает код 422 с ошибками валидации, если запрос не прошел. Вы не пишете этот код — это просто происходит. Это один из тех удобных моментов Laravel, которые делают разработку API приятной.
Проблема HTTP статус-кодов
HTTP статус-коды имеют большое значение. Клиентам вашего API нужно программно понять, что произошло, без парсинга сообщений об ошибках.
// Создание ресурса
return (new TaskResource($task))
->response()
->setStatusCode(201);
// Успешное удаление
return response()->json(null, 204);
// Не найдено (обработано автоматически с привязкой модели маршрута)
return response()->json([
'message' => 'Task not found'
], 404);
Привыкайте к 200 (OK), 201 (Created), 204 (No Content), 400 (Bad Request), 404 (Not Found) и 422 (Unprocessable Entity). Вы будете их использовать постоянно.
Что вы действительно изучаете
Этот проект не о управлении задачами. Это об понимании цикла запрос-ответ в контексте API. Это об изучении того, что Laravel предоставляет инструменты, специально разработанные для разработки API, и что их использование облегчает вашу жизнь.
Не переходите дальше, пока вы не сможете создать этот API и протестировать его с помощью инструмента типа Postman или Insomnia. Фактически отправляйте запросы. Смотрите, какие ответы вы получаете. Специально ломайте вещи, чтобы понять ответы об ошибках.
Проект 2: Blog API с категориями — Овладение отношениями
Ключевые концепции
- Eloquent отношения,
- eager loading,
- фильтрация,
- пагинация,
- N+1 запросы
Теперь мы добавляем сложность. Посты принадлежат категориям. Авторы пишут посты. Здесь вы начинаете видеть реальную мощь Eloquent, и где вы столкнетесь с первыми проблемами производительности, если не будете осторожны.
Схема
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->timestamps();
});
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->string('slug')->unique();
$table->text('content');
$table->timestamp('published_at')->nullable();
$table->timestamps();
});
Достаточно просто. Но отношения — это где дела становятся интересными.
class Post extends Model
{
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}
class Category extends Model
{
public function posts(): HasMany
{
return $this->hasMany(Post::class);
}
}
Проблема N+1
Вот контроллер, который выглядит хорошо, но убьет вашу базу данных:
public function index()
{
$posts = Post::all();
return PostResource::collection($posts);
}
Если ваш PostResource включает имя категории, вы только что создали N+1 запрос. Laravel выполнит один запрос для получения всех постов, затем один дополнительный запрос для каждого поста для получения его категории. Со 100 постами это 101 запрос.
Исправление — eager loading (жадная загрузка):
public function index()
{
$posts = Post::with(['category', 'author'])->get();
return PostResource::collection($posts);
}
Теперь Laravel выполняет всего три запроса: один для постов, один для всех категорий, один для всех авторов. Затем он сопоставляет их в PHP. Это разница между быстрым и медленным API.
Включите логирование запросов в развитии, чтобы увидеть это:
DB::listen(function ($query) {
Log::info($query->sql);
});
Вы будете удивлены, сколько запросов генерирует ваш невинный код.
Фильтрация и пагинация
Реальные API нуждаются в фильтрации. Пользователи хотят посты из конкретной категории, или опубликованные посты, или посты из диапазона дат. Не жестко кодируйте их — сделайте их гибкими.
public function index(Request $request)
{
$query = Post::with(['category', 'author']);
if ($request->has('category')) {
$query->whereHas('category', function ($q) use ($request) {
$q->where('slug', $request->category);
});
}
if ($request->has('author')) {
$query->where('user_id', $request->author);
}
if ($request->boolean('published')) {
$query->whereNotNull('published_at');
}
return PostResource::collection(
$query->latest()->paginate(15)
);
}
Заметьте вызов paginate(). Никогда не возвращайте неограниченные коллекции. Всегда пагинируйте. Laravel’s пагинация автоматически включает meta данные и links в JSON ответ, который ваш фронтенд может использовать для создания элементов управления пагинацией.
Вложенные ресурсы
Должны ли категории иметь свой собственный posts endpoint? Да.
Route::get('categories/{category}/posts', function (Category $category) {
return PostResource::collection(
$category->posts()->with('author')->latest()->paginate()
);
});
Это чище, чем фильтрация на основном posts index. Это также более семантично — вы спрашиваете «посты в этой категории», а не «посты отфильтрованные по категориям». Разница имеет значение, когда вы создаете интуитивный API.
Что вы изучаете
Отношения — это суперсила Eloquent, но они также скрывают проблемы производительности. Этот проект учит вас думать о запросах, а не просто о моделях. Он учит вас, что ->with() почти всегда необходимо. Он учит вас, что пагинация не опциональна.
Вы также изучаете, что дизайн API включает компромиссы. Включаете ли вы полный объект категории в ответ поста или только ID категории? Нет универсально правильного ответа — это зависит от вашего варианта использования.
Проект 3: Bookmark Manager API — Правильно выполненная аутентификация
Ключевые концепции
- Laravel Sanctum,
- аутентификация на основе токенов,
- защищенные маршруты,
- данные, привязанные к пользователям
Здесь ваш API становится реальным. Пользователи могут регистрироваться, входить и управлять своими данными. Другие пользователи не могут их видеть. Это основа каждого SaaS-приложения, которое вы когда-либо создадите.
Настройка Sanctum
Laravel Sanctum создан точно для этого варианта использования: аутентификация на основе токенов для SPA и мобильных приложений. Это проще, чем Passport, и идеально для большинства приложений.
composer require laravel/sanctum
php artisan vendor:publish --provider="Laravel\Sanctum\SanctumServiceProvider"
php artisan migrate
Добавьте middleware Sanctum в вашу группу middleware API в app/Http/Kernel.php:
'api' => [
\Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful::class,
'throttle:api',
\Illuminate\Routing\Middleware\SubstituteBindings::class,
],
И добавьте trait HasApiTokens в вашу User модель:
use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
// ...
}
Регистрация и вход
Вот базовый endpoint регистрации:
public function register(Request $request)
{
$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' => $request->name,
'email' => $request->email,
'password' => Hash::make($request->password),
]);
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
], 201);
}
И вход:
public function login(Request $request)
{
$request->validate([
'email' => ['required', 'email'],
'password' => ['required'],
]);
if (!Auth::attempt($request->only('email', 'password'))) {
return response()->json([
'message' => 'Invalid credentials'
], 401);
}
$user = User::where('email', $request->email)->firstOrFail();
$token = $user->createToken('auth-token')->plainTextToken;
return response()->json([
'user' => new UserResource($user),
'token' => $token,
]);
}
Клиенты вашего API сохраняют этот токен и отправляют его с каждым запросом:
Authorization: Bearer {token}
Защита маршрутов
Теперь оберните ваши bookmark-маршруты в аутентификацию:
Route::middleware('auth:sanctum')->group(function () {
Route::apiResource('bookmarks', BookmarkController::class);
});
Вот и все. Любой запрос к этим маршрутам без действительного токена получает 401 ответ автоматически.
Привязка данных к пользователям
Это критично. Пользователи должны видеть только свои собственные закладки. Каждый запрос должен быть привязан:
public function index(Request $request)
{
return BookmarkResource::collection(
$request->user()->bookmarks()->latest()->paginate()
);
}
public function store(Request $request)
{
$bookmark = $request->user()->bookmarks()->create(
$request->validated()
);
return new BookmarkResource($bookmark);
}
Заметьте, что мы используем $request->user() для получения аутентифицированного пользователя, затем обращаемся к его закладкам через отношение. Это гарантирует, что пользователи не могут получить доступ к данным других пользователей.
Для операций обновления и удаления вам нужны политики авторизации:
public function update(Request $request, Bookmark $bookmark)
{
$this->authorize('update', $bookmark);
$bookmark->update($request->validated());
return new BookmarkResource($bookmark);
}
И политика:
public function update(User $user, Bookmark $bookmark): bool
{
return $user->id === $bookmark->user_id;
}
Laravel проверяет это автоматически. Если политика возвращает false, Laravel возвращает 403 Forbidden ответ. Вы не пишете код.
Что вы изучаете
Аутентификация — это обязательное условие для любого реального приложения. Этот проект учит вас, как реализовать это правильно с современными инструментами Laravel. Вы изучаете, что аутентификация (кто ты?) и авторизация (что ты можешь делать?) — это отдельные задачи, и Laravel оба обрабатывает элегантно.
Вы также изучаете, что безопасность — это не надстройка — это встроено в вашу архитектуру. Привязка запросов к аутентифицированным пользователям с самого начала легче, чем пытаться добавить это позже.
Проект 4: Recipe API с поиском — сложность запросов
Ключевые концепции
- Полнотекстовый поиск,
- сложная фильтрация,
- области действия запросов,
- оптимизация поиска
Пользователям нужно найти рецепты. По ингредиентам. По кухне. По диетическим ограничениям. Здесь простые where пункты перестают быть достаточными, и вам нужно думать о том, как сделать запросы составными и поддерживаемыми.
Схема
Schema::create('recipes', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('title');
$table->text('description');
$table->text('instructions');
$table->string('cuisine')->nullable();
$table->integer('prep_time'); // in minutes
$table->integer('cook_time');
$table->integer('servings');
$table->json('dietary_info')->nullable(); // ['vegetarian', 'gluten-free']
$table->timestamps();
});
Schema::create('ingredients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->timestamps();
});
Schema::create('ingredient_recipe', function (Blueprint $table) {
$table->foreignId('recipe_id')->constrained()->onDelete('cascade');
$table->foreignId('ingredient_id')->constrained()->onDelete('cascade');
$table->string('quantity');
$table->string('unit')->nullable();
});
Области действия запросов сохраняют контроллеры чистыми
Не делайте это:
public function index(Request $request)
{
$query = Recipe::query();
if ($request->has('cuisine')) {
$query->where('cuisine', $request->cuisine);
}
if ($request->has('max_time')) {
$query->where(DB::raw('prep_time + cook_time'), '<=', $request->max_time);
}
if ($request->has('dietary')) {
$dietary = json_decode($request->dietary);
$query->where(function ($q) use ($dietary) {
foreach ($dietary as $diet) {
$q->orWhereJsonContains('dietary_info', $diet);
}
});
}
if ($request->has('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('title', 'like', "%{$search}%")
->orWhere('description', 'like', "%{$search}%");
});
}
return RecipeResource::collection($query->paginate());
}
Этот контроллер делает слишком много. Извлеките логику запроса в области действия:
class Recipe extends Model
{
public function scopeCuisine($query, $cuisine)
{
return $query->where('cuisine', $cuisine);
}
public function scopeMaxTotalTime($query, $minutes)
{
return $query->whereRaw('(prep_time + cook_time) <= ?', [$minutes]);
}
public function scopeDietary($query, array $requirements)
{
foreach ($requirements as $requirement) {
$query->whereJsonContains('dietary_info', $requirement);
}
return $query;
}
public function scopeSearch($query, $term)
{
return $query->where(function ($q) use ($term) {
$q->where('title', 'like', "%{$term}%")
->orWhere('description', 'like', "%{$term}%");
});
}
public function scopeWithIngredient($query, $ingredientId)
{
return $query->whereHas('ingredients', function ($q) use ($ingredientId) {
$q->where('ingredient_id', $ingredientId);
});
}
}
Теперь ваш контроллер читаем:
public function index(Request $request)
{
$query = Recipe::with(['user', 'ingredients']);
if ($request->filled('cuisine')) {
$query->cuisine($request->cuisine);
}
if ($request->filled('max_time')) {
$query->maxTotalTime($request->max_time);
}
if ($request->filled('dietary')) {
$query->dietary($request->dietary);
}
if ($request->filled('search')) {
$query->search($request->search);
}
if ($request->filled('ingredient')) {
$query->withIngredient($request->ingredient);
}
return RecipeResource::collection(
$query->latest()->paginate()
);
}
Каждая область действия переиспользуется, тестируется и выразительна. Вот как вы сохраняете контроллеры тонкими и запросы поддерживаемыми.
Полнотекстовый поиск с Scout
Подход LIKE работает для небольших наборов данных, но не масштабируется. Для реального поиска используйте Laravel Scout с драйвером типа Meilisearch или Algolia.
composer require laravel/scout
php artisan vendor:publish --provider="Laravel\Scout\ScoutServiceProvider"
Сделайте вашу модель поддерживающей поиск:
use Laravel\Scout\Searchable;
class Recipe extends Model
{
use Searchable;
public function toSearchableArray()
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'cuisine' => $this->cuisine,
'ingredients' => $this->ingredients->pluck('name')->toArray(),
];
}
}
Теперь поиск становится простым и понятным:
$recipes = Recipe::search($request->search)
->query(fn ($query) => $query->with('ingredients'))
->paginate();
Scout делает всю работу за вас. Он автоматически синхронизирует вашу базу данных с индексом поиска.
Что вы изучаете
Этот проект посвящен управлению сложностью. По мере того, как ваши запросы становятся более сложными, вам нужны шаблоны, чтобы сохранить их поддерживаемыми. Области действия запросов — один из таких шаблонов. Они позволяют вам составлять запросы из небольших, понятных кусков.
Вы также изучаете, что некоторые проблемы нуждаются в специализированных инструментах. Полнотекстовый поиск с LIKE хорош для демонстраций, но production приложения нуждаются в реальной инфраструктуре поиска. Экосистема Laravel предоставляет это без необходимости переизобретать колеса.
Проект 5: Expense Tracker API с отчетами — агрегация данных
Ключевые концепции
- Агрегации базы данных,
- фильтрация по датам,
- группирование,
- преобразование данных,
- вычисленные значения
API не просто возвращают необработанные данные — они часто должны выполнять расчеты и преобразования. Этот проект учит вас использовать конструктор запросов Laravel для агрегаций и как структурировать сложные аналитические данные в ответах вашего API.
Домен
Пользователи отслеживают расходы. Каждый расход имеет сумму, категорию, дату и необязательные заметки. API должно предоставлять не просто список расходов, но агрегированные отчеты: итоги по категориям, ежемесячные сводки, сравнения год к году.
Schema::create('expenses', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->foreignId('category_id')->constrained();
$table->decimal('amount', 10, 2);
$table->date('date');
$table->string('description')->nullable();
$table->timestamps();
});
Schema::create('categories', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('color')->nullable();
$table->timestamps();
});
Базовые агрегации
Получение итогов по категориям:
public function categoryTotals(Request $request)
{
$totals = $request->user()
->expenses()
->selectRaw('category_id, SUM(amount) as total')
->groupBy('category_id')
->with('category:id,name,color')
->get();
return response()->json([
'data' => $totals->map(fn ($item) => [
'category' => [
'id' => $item->category->id,
'name' => $item->category->name,
'color' => $item->category->color,
],
'total' => (float) $item->total,
])
]);
}
Заметьте selectRaw и groupBy. Это функции агрегации SQL, и они мощные. Но они также означают, что вы больше не получаете полные Eloquent модели — вы получаете частичные модели с только выбранными полями.
Фильтрация по диапазону дат
public function summary(Request $request)
{
$request->validate([
'start_date' => ['required', 'date'],
'end_date' => ['required', 'date', 'after_or_equal:start_date'],
]);
$expenses = $request->user()
->expenses()
->whereBetween('date', [$request->start_date, $request->end_date])
->with('category')
->get();
return response()->json([
'period' => [
'start' => $request->start_date,
'end' => $request->end_date,
],
'total_expenses' => $expenses->sum('amount'),
'count' => $expenses->count(),
'average' => $expenses->avg('amount'),
'by_category' => $expenses->groupBy('category_id')->map(function ($items) {
return [
'category' => $items->first()->category->name,
'total' => $items->sum('amount'),
'count' => $items->count(),
];
})->values(),
]);
}
Это использует методы коллекции для агрегации, а не запросы базы данных. Для меньших наборов данных это хорошо и часто более гибко. Для больших наборов данных вы захотели бы отправить агрегацию на базу данных.
Ежемесячные отчеты
public function monthlyReport(Request $request, int $year)
{
$expenses = $request->user()
->expenses()
->whereYear('date', $year)
->selectRaw('MONTH(date) as month, SUM(amount) as total, COUNT(*) as count')
->groupBy('month')
->orderBy('month')
->get();
// Fill in missing months with zeros
$months = collect(range(1, 12))->map(function ($month) use ($expenses) {
$data = $expenses->firstWhere('month', $month);
return [
'month' => $month,
'month_name' => Carbon::create()->month($month)->format('F'),
'total' => $data ? (float) $data->total : 0,
'count' => $data ? $data->count : 0,
];
});
return response()->json([
'year' => $year,
'months' => $months,
'yearly_total' => $months->sum('total'),
]);
}
Это общий шаблон: запрос агрегированных данных, затем заполнение пробелов значениями по умолчанию. Без заполнения пробелов, месяцы без расходов не будут отображаться в результатах, что разрушит любую графику фронтенда.
Что вы изучаете
Этот проект учит вас, что API — это больше, чем просто CRUD. Реальные приложения должны преобразовывать и агрегировать данные. Вы изучаете, когда использовать агрегации базы данных в сравнении с методами коллекции. Вы изучаете, как структурировать сложные аналитические ответы, чтобы фронтенды могли их легко использовать.
Вы также изучаете, что обработка дат удивительно сложна. Часовые пояса, диапазоны дат и агрегация по периодам времени имеют граничные случаи. Carbon — ваш друг здесь, но вам все еще нужно тщательно думать о том, что вы вычисляете.
Проект 6: Event RSVP API — сложные отношения и состояние
Ключевые концепции
- Отношения многие-ко-многим,
- сводные таблицы,
- управление состоянием,
- ограничения вместимости
Отношения многие-ко-многим — это где Eloquent действительно сверкает, но они также про то, где дела становятся сложными. События имеют участников. Участники могут RSVP несколько событий. Само отношение имеет состояние (идет, может, не идет). События имеют ограничения вместимости. Это реальная сложность мира.
Схема
Schema::create('events', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained(); // event creator
$table->string('title');
$table->text('description');
$table->string('location');
$table->dateTime('starts_at');
$table->dateTime('ends_at');
$table->integer('capacity')->nullable();
$table->timestamps();
});
Schema::create('event_user', function (Blueprint $table) {
$table->id();
$table->foreignId('event_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->enum('status', ['going', 'maybe', 'not_going']);
$table->text('note')->nullable();
$table->timestamps();
$table->unique(['event_id', 'user_id']);
});
Отношения
class Event extends Model
{
public function creator(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
public function attendees(): BelongsToMany
{
return $this->belongsToMany(User::class)
->withPivot('status', 'note')
->withTimestamps();
}
public function confirmed(): BelongsToMany
{
return $this->attendees()->wherePivot('status', 'going');
}
public function maybe(): BelongsToMany
{
return $this->attendees()->wherePivot('status', 'maybe');
}
}
class User extends Model
{
public function events(): BelongsToMany
{
return $this->belongsToMany(Event::class)
->withPivot('status', 'note')
->withTimestamps();
}
public function createdEvents(): HasMany
{
return $this->hasMany(Event::class);
}
}
Заметьте вызов withPivot(). Это говорит Eloquent включить эти столбцы из сводной таблицы при обращении к отношению. Без него, вы не можете видеть статус RSVP.
Логика RSVP
public function rsvp(Request $request, Event $event)
{
$request->validate([
'status' => ['required', 'in:going,maybe,not_going'],
'note' => ['nullable', 'string', 'max:500'],
]);
// Check capacity if they're marking as "going"
if ($request->status === 'going' && $event->capacity) {
$confirmed = $event->confirmed()->count();
if ($confirmed >= $event->capacity) {
return response()->json([
'message' => 'This event is at capacity'
], 422);
}
}
// Attach or update the RSVP
$event->attendees()->syncWithoutDetaching([
$request->user()->id => [
'status' => $request->status,
'note' => $request->note,
]
]);
return response()->json([
'message' => 'RSVP updated successfully',
'status' => $request->status,
]);
}
Метод syncWithoutDetaching идеален здесь. Он обновляет сводную запись, если она существует, создает ее, если нет, но не удаляет другие отношения.
Доступ к данным сводной таблицы
Когда вы получаете событие с участниками, данные сводной таблицы доступны:
$event = Event::with('attendees')->find($id);
foreach ($event->attendees as $attendee) {
echo $attendee->pivot->status; // 'going', 'maybe', or 'not_going'
echo $attendee->pivot->created_at; // when they RSVP'd
}
Это раскрывается в вашем ресурсе:
class EventResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->title,
'description' => $this->description,
'starts_at' => $this->starts_at->toISOString(),
'capacity' => $this->capacity,
'attendee_count' => $this->confirmed()->count(),
'spots_remaining' => $this->capacity
? max(0, $this->capacity - $this->confirmed()->count())
: null,
'attendees' => $this->whenLoaded('attendees', function () {
return $this->attendees->map(fn ($user) => [
'id' => $user->id,
'name' => $user->name,
'status' => $user->pivot->status,
'rsvp_at' => $user->pivot->created_at->toISOString(),
]);
}),
];
}
}
Что вы изучаете
Отношения многие-ко-многим со сводными данными распространены в реальных приложениях: пользователи и роли, продукты и заказы, студенты и курсы. Этот проект учит вас правильно работать с ними в Laravel.
Вы изучаете, что отношения — это не просто ссылки между таблицами — они являются первоклассными структурами данных со своими собственными атрибутами и логикой. Сводная таблица сама по себе является моделью, даже если вы не всегда явно определяете ее как таковую.
Вы также изучаете управление состоянием на уровне базы данных. RSVP имеют состояние. События имеют ограничения вместимости. Это не просто модели данных — это бизнес-правила, которые ваш API должен требовать.
Проект 7: Multi-Tenant SaaS API — привязка всего
Ключевые концепции
- Многопользовательность,
- глобальные области действия,
- доступ на основе команды,
- разрешения на основе ролей
Реальные SaaS приложения многопользовательские. Пользователи принадлежат командам (или организациям, рабочим пространствам, аккаунтам — выберите свою терминологию). Все данные принадлежат команде. Пользователи могут принадлежать нескольким командам. Это меняет все о том, как вы структурируете ваше приложение.
Основная концепция
Каждая таблица, содержащая данные пользователя, нужна team_id. Каждый запрос должен быть привязан к текущей команде. Пользователи могут переключаться между командами. Это сложнее, чем звучит.
Schema::create('teams', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('slug')->unique();
$table->foreignId('owner_id')->constrained('users');
$table->timestamps();
});
Schema::create('team_user', function (Blueprint $table) {
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('role')->default('member');
$table->timestamps();
$table->primary(['team_id', 'user_id']);
});
Schema::create('projects', function (Blueprint $table) {
$table->id();
$table->foreignId('team_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->text('description')->nullable();
$table->timestamps();
});
Глобальные области действия для автоматической фильтрации
Не добавляйте вручную where('team_id', ...) к каждому запросу. Используйте глобальные области действия:
namespace App\Models\Scopes;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Scope;
class TeamScope implements Scope
{
public function apply(Builder $builder, Model $model): void
{
if ($teamId = $this->getCurrentTeamId()) {
$builder->where("{$model->getTable()}.team_id", $teamId);
}
}
protected function getCurrentTeamId(): ?int
{
return auth()->user()?->currentTeam?->id;
}
}
Примените это к вашим моделям:
class Project extends Model
{
protected static function booted(): void
{
static::addGlobalScope(new TeamScope);
}
}
Теперь каждый запрос на Project автоматически привязан к текущей команде. Данные не смогут случйано утечь между командами.
Переключение команды
Пользователи должны переключаться между командами. Сохраняйте текущую команду на пользователе:
class User extends Model
{
public function currentTeam(): BelongsTo
{
return $this->belongsTo(Team::class, 'current_team_id');
}
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
->withPivot('role')
->withTimestamps();
}
public function switchTeam(Team $team): void
{
if (!$this->teams->contains($team->id)) {
throw new \Exception('User does not belong to this team');
}
$this->current_team_id = $team->id;
$this->save();
}
}
Endpoint переключения:
public function switch(Request $request, Team $team)
{
$request->user()->switchTeam($team);
return response()->json([
'message' => 'Switched to team: ' . $team->name,
'current_team' => new TeamResource($team),
]);
}
Разрешения на основе ролей
Используйте пакет Spatie’s Laravel Permission. Не создавайте это с нуля.
composer require spatie/laravel-permission
php artisan vendor:publish --provider="Spatie\Permission\PermissionServiceProvider"
php artisan migrate
Определите роли и разрешения за команду:
$team = Team::find(1);
$user = User::find(1);
// Assign role within team context
$user->assignRole('admin');
// Check permissions
if ($user->hasPermissionTo('create projects')) {
// allowed
}
// In your policies
public function create(User $user): bool
{
return $user->hasPermissionTo('create projects');
}
Что вы изучаете
Многопользовательность — это архитектурное решение, которое влияет на все ваше приложение. Этот проект учит вас, что изоляция — это не только безопасность — это создание логически правильного приложения. Пользователи не должны случайно видеть данные из других команд, даже если в вашем коде есть ошибка.
Вы изучаете, что глобальные области действия мощные, но должны использоваться осторожно. Они невидимы, что является как их сила, так и их опасностью. Вы также изучаете, что некоторые проблемы настолько распространены, что использование боевых пакетов — правильный выбор.
Проект 8: API с вебхуками — асинхронные уведомления
Ключевые концепции
- Вебхуки,
- Laravel события,
- работники очереди,
- логика повтора,
- проверка подписи
Вебхуки позволяют вашему API уведомлять другие сервисы, когда что-то происходит. Пользователь создал аккаунт? Запустите вебхук. Платеж обработан? Запустите вебхук. Вот как современные API интегрируются друг с другом.
Схема
Schema::create('webhooks', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('url');
$table->json('events'); // ['user.created', 'project.updated']
$table->string('secret');
$table->boolean('active')->default(true);
$table->timestamps();
});
Schema::create('webhook_calls', function (Blueprint $table) {
$table->id();
$table->foreignId('webhook_id')->constrained()->onDelete('cascade');
$table->string('event');
$table->json('payload');
$table->integer('response_status')->nullable();
$table->text('response_body')->nullable();
$table->integer('attempt')->default(1);
$table->timestamp('delivered_at')->nullable();
$table->timestamps();
});
События и слушатели
Когда что-то происходит, запустите событие:
event(new ProjectCreated($project));
Само событие:
namespace App\Events;
use App\Models\Project;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class ProjectCreated
{
use Dispatchable, SerializesModels;
public function __construct(
public Project $project
) {}
}
Слушайте события и запустите вебхуки:
namespace App\Listeners;
use App\Events\ProjectCreated;
use App\Jobs\SendWebhookJob;
use App\Models\Webhook;
class SendProjectWebhooks
{
public function handle(ProjectCreated $event): void
{
$webhooks = Webhook::where('active', true)
->where('user_id', $event->project->user_id)
->whereJsonContains('events', 'project.created')
->get();
foreach ($webhooks as $webhook) {
SendWebhookJob::dispatch($webhook, [
'event' => 'project.created',
'data' => [
'id' => $event->project->id,
'name' => $event->project->name,
'created_at' => $event->project->created_at->toISOString(),
],
]);
}
}
}
Работа с вебхуком
namespace App\Jobs;
use App\Models\Webhook;
use App\Models\WebhookCall;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class SendWebhookJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public $tries = 3;
public $backoff = [60, 300, 900]; // 1 min, 5 min, 15 min
public function __construct(
public Webhook $webhook,
public array $payload
) {}
public function handle(): void
{
$signature = hash_hmac('sha256', json_encode($this->payload), $this->webhook->secret);
$response = Http::timeout(10)
->withHeaders([
'X-Webhook-Signature' => $signature,
'Content-Type' => 'application/json',
])
->post($this->webhook->url, $this->payload);
WebhookCall::create([
'webhook_id' => $this->webhook->id,
'event' => $this->payload['event'],
'payload' => $this->payload,
'response_status' => $response->status(),
'response_body' => $response->body(),
'attempt' => $this->attempts(),
'delivered_at' => $response->successful() ? now() : null,
]);
if (!$response->successful()) {
throw new \Exception('Webhook delivery failed: ' . $response->status());
}
}
}
Что тут происходит:
- Отправляет вебхук с заголовком подписи
- Логирует каждую попытку
- Переделывает с backoff если не удается
- Истекает по истечении 10 секунд
Проверка подписи
Получающий сервис должен проверить подпись:
// On the receiving end
$signature = $request->header('X-Webhook-Signature');
$payload = $request->getContent();
$expectedSignature = hash_hmac('sha256', $payload, $secret);
if (!hash_equals($expectedSignature, $signature)) {
return response()->json(['error' => 'Invalid signature'], 401);
}
Это предотвращает подделку вебхука. Без проверки подписи, любой мог бы отправить поддельные вебхуки конечным точкам ваших пользователей.
Что вы изучаете
Вебхуки — это то, как API взаимодействуют. Этот проект учит вас, что не все может происходить синхронно — некоторые операции должны быть поставлены в очередь. Вы изучаете переделки работы, стратегии backoff и обработку истечения времени.
Вы также изучаете, что безопасность имеет значение даже в машинном общении. Подписи гарантируют, что вебхуки аутентичны.
Что наиболее важно, вы изучаете, что API нужна видимость. Логирование попыток вебхука позволяет пользователям отлаживать проблемы интеграции. Хорошие API облегчают отладку.
Проект 9: Rate-Limited Public API — управление доступом
Ключевые концепции
- Rate limiting,
- API ключи,
- отслеживание использования,
- middleware,
- стратегии регулирования
Public API нуждаются в защите от злоупотребления. Вы не можете позволить одному клиенту молотить ваши серверы неограниченными запросами. Этот проект учит вас, как реализовать сложное rate limiting и отслеживание использования.
Управление API ключами
Schema::create('api_keys', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('name');
$table->string('key')->unique();
$table->string('tier')->default('free'); // free, pro, enterprise
$table->timestamp('last_used_at')->nullable();
$table->boolean('active')->default(true);
$table->timestamps();
});
Schema::create('api_key_usages', function (Blueprint $table) {
$table->id();
$table->foreignId('api_key_id')->constrained();
$table->string('endpoint');
$table->timestamp('requested_at');
$table->integer('response_status');
$table->integer('response_time_ms');
$table->index(['api_key_id', 'requested_at']);
});
Генерируйте ключи безопасно:
public function create(Request $request)
{
$request->validate([
'name' => ['required', 'string', 'max:255'],
]);
$apiKey = $request->user()->apiKeys()->create([
'name' => $request->name,
'key' => 'sk_' . bin2hex(random_bytes(32)),
'tier' => 'free',
]);
return response()->json([
'key' => $apiKey->key,
'name' => $apiKey->name,
'message' => 'Store this key securely. It will not be shown again.',
], 201);
}
Показывайте ключ только один раз. Никогда не сохраняйте его в открытом виде, если можете его захешировать (хотя для API ключей, открытый текст часто необходим для проверки).
Middleware Rate Limiting
Встроенный rate limiting Laravel мощный:
// In RouteServiceProvider
RateLimiter::for('api', function (Request $request) {
$apiKey = ApiKey::where('key', $request->bearerToken())->first();
if (!$apiKey) {
return Limit::perMinute(10); // Unauthenticated: 10/min
}
return match ($apiKey->tier) {
'free' => Limit::perMinute(60)->by($apiKey->id),
'pro' => Limit::perMinute(300)->by($apiKey->id),
'enterprise' => Limit::none(),
};
});
Примените к маршрутам:
Route::middleware('throttle:api')->group(function () {
// Rate-limited routes
});
Laravel автоматически возвращает 429 Too Many Requests ответ, когда лимиты превышены, включая X-RateLimit-* заголовки.
Custom Middleware для API ключей
namespace App\Http\Middleware;
use App\Models\ApiKey;
use Closure;
use Illuminate\Http\Request;
class AuthenticateApiKey
{
public function handle(Request $request, Closure $next)
{
$key = $request->bearerToken();
if (!$key) {
return response()->json([
'error' => 'API key required'
], 401);
}
$apiKey = ApiKey::where('key', $key)
->where('active', true)
->first();
if (!$apiKey) {
return response()->json([
'error' => 'Invalid API key'
], 401);
}
$apiKey->update(['last_used_at' => now()]);
$request->merge(['api_key' => $apiKey]);
return $next($request);
}
}
Отслеживание использования
Отслеживайте каждый запрос:
namespace App\Http\Middleware;
use App\Models\ApiKeyUsage;
use Closure;
use Illuminate\Http\Request;
class TrackApiUsage
{
public function handle(Request $request, Closure $next)
{
$startTime = microtime(true);
$response = $next($request);
$duration = (microtime(true) - $startTime) * 1000;
if ($apiKey = $request->get('api_key')) {
ApiKeyUsage::create([
'api_key_id' => $apiKey->id,
'endpoint' => $request->path(),
'requested_at' => now(),
'response_status' => $response->status(),
'response_time_ms' => round($duration),
]);
}
return $response;
}
}
Эти данные позволяют вам:
- Показывать пользователям их использование
- Выставлять счета на основе потребления
- Определить медленные endpoints
- Обнаружить шаблоны злоупотребления
Endpoint аналитики использования
public function usage(Request $request)
{
$apiKey = $request->get('api_key');
$today = $apiKey->usages()
->whereDate('requested_at', today())
->count();
$thisMonth = $apiKey->usages()
->whereMonth('requested_at', now()->month)
->count();
$byEndpoint = $apiKey->usages()
->whereMonth('requested_at', now()->month)
->groupBy('endpoint')
->selectRaw('endpoint, COUNT(*) as count')
->orderByDesc('count')
->limit(10)
->get();
return response()->json([
'today' => $today,
'this_month' => $thisMonth,
'limit' => $this->getLimitForTier($apiKey->tier),
'top_endpoints' => $byEndpoint,
]);
}
Что вы изучаете
Этот проект учит вас, что public API отличаются от аутентифицированных пользовательских API. Вам нужна другая аутентификация (API ключи vs session/token), разные стратегии rate limiting и полное отслеживание использования.
Вы изучаете, что rate limiting — это не просто о предотвращении злоупотребления — это об управлении ресурсами и создании уровней цен. Вы также изучаете, что видимость критична. Пользователи должны видеть их использование. Вы должны видеть, как ваш API используется.
Проект 10: GraphQL альтернатива — другая парадигма
Ключевые концепции
- GraphQL с Lighthouse,
- дизайн схемы,
- N+1 предотвращение с dataloaders,
- resolvers
GraphQL не лучше чем REST, но решает разные проблемы. Этот проект учит вас альтернативной парадигме API и помогает вам понять, когда каждый подход имеет смысл.
Настройка Lighthouse
composer require nuwave/lighthouse
php artisan vendor:publish --tag=lighthouse-schema
Lighthouse — это Laravel-native GraphQL. Это использует ваши Eloquent модели и следует соглашениям Laravel.
Схема
GraphQL начинается со схемы, которая определяет весь ваш API:
type Query {
posts(first: Int! @paginate): [Post!]! @paginate
post(id: ID! @eq): Post @find
me: User @auth
}
type Post {
id: ID!
title: String!
content: String!
author: User! @belongsTo
comments: [Comment!]! @hasMany
created_at: DateTime!
updated_at: DateTime!
}
type User {
id: ID!
name: String!
email: String!
posts: [Post!]! @hasMany
}
type Comment {
id: ID!
content: String!
post: Post! @belongsTo
author: User! @belongsTo
created_at: DateTime!
}
Эта схема определяет все: типы, отношения, запросы. Lighthouse использует директивы (типа @paginate, @hasMany) для автоматического создания resolvers на основе ваших Eloquent моделей.
Автоматический CRUD с Eloquent
Потому что Lighthouse понимает Laravel, вы получаете свободные запросы:
query {
posts(first: 10) {
data {
id
title
author {
name
}
comments {
content
author {
name
}
}
}
}
}
Lighthouse автоматически:
- Пагинирует результаты
- Eager загружает отношения для предотвращения N+1
- Преобразует модели в соответствие со схемой
- Обрабатывает аутентификацию с
@auth
Custom Resolvers
Для сложной логики, напишите custom resolvers:
namespace App\GraphQL\Queries;
class PostsByTag
{
public function __invoke($rootValue, array $args)
{
return Post::whereHas('tags', function ($query) use ($args) {
$query->where('slug', $args['tag']);
})->paginate($args['first']);
}
}
Ссылаетесь в вашей схеме:
type Query {
postsByTag(tag: String!, first: Int!): [Post!]!
@paginate
@field(resolver: "App\\GraphQL\\Queries\\PostsByTag")
}
Мутации
GraphQL мутации похожи на POST/PUT/DELETE в REST:
type Mutation {
createPost(input: CreatePostInput! @spread): Post
@create
@guard
updatePost(id: ID!, input: UpdatePostInput! @spread): Post
@update
@guard
deletePost(id: ID!): Post
@delete
@guard
}
input CreatePostInput {
title: String! @rules(apply: ["required", "max:255"])
content: String! @rules(apply: ["required"])
category_id: ID!
}
Директива @guard требует аутентификации. Директива @rules применяет валидацию Laravel. Это шаблоны Laravel в GraphQL.
Когда GraphQL имеет смысл
GraphQL решает проблему «over-fetching» и «under-fetching». В REST, вам может потребоваться несколько запросов:
GET /api/posts/1
GET /api/posts/1/author
GET /api/posts/1/comments
GET /api/posts/1/comments/123/author
С GraphQL, один запрос:
query {
post(id: 1) {
title
author { name }
comments {
content
author { name }
}
}
}
Клиент спрашивает точно то, что ему нужно. Не больше, не меньше.
Но GraphQL имеет затраты:
- Сложнее для реализации
- Сложнее кешировать (нет кеширования на основе URL)
- Сложность запроса может быть сложно ограничить
- REST проще для простых случаев использования
Что вы изучаете
Этот проект учит вас, что REST — не единственный способ построить API. GraphQL предлагает разные компромиссы: большая гибкость для клиентов, большая сложность для серверов. Ни один не является универсально лучше.
Вы изучаете, что экосистема Laravel поддерживает несколько парадигм. Lighthouse доказывает, что GraphQL может чувствовать себя родным для Laravel, используя те же модели, валидацию и шаблоны, которые вы уже знаете.
За пределами проектов
Эти десять проектов дают вам основу, но это не полное руководство. Реальные production API нуждаются в большем:
Тестирование
Каждый endpoint должен быть протестирован. Используйте Pest или PHPUnit:
test('authenticated users can create posts', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)->postJson('/api/posts', [
'title' => 'Test Post',
'content' => 'Content here',
]);
$response->assertCreated()
->assertJsonStructure(['data' => ['id', 'title', 'content']]);
$this->assertDatabaseHas('posts', [
'title' => 'Test Post',
'user_id' => $user->id,
]);
});
Тестирование API часто проще, чем тестирование full-stack приложений, потому что вы просто проверяете JSON ответы. Пишите тесты при создании функций, не после.
Документация
API бесполезен, если никто не знает, как его использовать. Я использую Scribe для автоматического создания документации:
composer require --dev knuckleswtf/scribe
php artisan vendor:publish --tag=scribe-config
php artisan scribe:generate
Scribe читает ваши маршруты, контроллеры и Form Requests для автоматического создания документации. Добавляйте аннотации для ясности:
/**
* Create a new post
*
* Creates a new post for the authenticated user.
*
* @bodyParam title string required The post title. Example: My First Post
* @bodyParam content string required The post content. Example: This is the content.
*
* @response 201 {"data":{"id":1,"title":"My First Post"}}
*/
public function store(StorePostRequest $request)
{
// ...
}
Хорошая документация так же важна, как хороший код.
Версионирование
API нуждаются в версионировании. Разрывающие изменения происходят. Не разрывайте существующих клиентов:
Route::prefix('v1')->group(function () {
// Version 1 routes
});
Route::prefix('v2')->group(function () {
// Version 2 routes with breaking changes
});
Или используйте версионирование на основе заголовков. Либо работает. Просто будьте последовательны.
Обработка ошибок
Последовательные ответы об ошибках имеют значение:
namespace App\Exceptions;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class Handler extends ExceptionHandler
{
public function render($request, Throwable $e)
{
if ($request->expectsJson()) {
if ($e instanceof NotFoundHttpException) {
return response()->json([
'message' => 'Resource not found'
], 404);
}
if ($e instanceof ValidationException) {
return response()->json([
'message' => 'Validation failed',
'errors' => $e->errors()
], 422);
}
return response()->json([
'message' => $e->getMessage()
], 500);
}
return parent::render($request, $e);
}
}
Клиенты никогда не должны видеть HTML страницы об ошибках или traces в production.
Мониторинг
Вам нужно знать, когда ваш API разбит. Используйте инструменты типа Sentry или Bugsnag для отслеживания ошибок. Используйте Laravel Telescope в development для просмотра каждого запроса, работы и события.
Установите endpoints проверки здоровья:
Route::get('/health', function () {
return response()->json([
'status' => 'healthy',
'timestamp' => now()->toISOString(),
]);
});
Ваши инструменты мониторинга могут пинговать это для проверки, что ваш API отвечает.
Реальное обучение начинается далее
Вот истина: вы не изучаете концепции, читая о них. Вы изучаете их, создавая их, совершая ошибки, переделывая и создавая их снова.
Выберите один проект из этого списка. Не выбирайте самый сложный, чтобы доказать что-то. Выберите тот, который звучит интересно. Создайте его полностью: тесты, документация, обработка ошибок, все. Разверните его где-то. Позвольте ему работать неделю. Затем вернитесь и переделайте это с тем, что вы изучили.
Затем выберите следующий проект.
Цель — это не собрать завершенные проекты в GitHub репо. Цель — интернализировать шаблоны, которые делают Laravel API поддерживаемыми. Вы хотите добраться до точки, где вы не думаете о том, использовать ли API Resource — вы просто используете один. Где rate limiting — это не что-то, что вы добавляете позже — это часть вашей начальной установки.
Это требует времени. Это требует повторения. Это требует построения одних и тех же шаблонов достаточно раз, чтобы они стали автоматическими.
Эти проекты дают вам структуру. Но обучение? Это только происходит, когда вы действительно пишете код.

