Advanced Laravel 13 API Tutorial: Professional Design Patterns
An advanced Laravel 13 tutorial for learning versioning, authentication, filtering, authorization, testing, and API documentation.

Advanced Laravel 13 API Tutorial
This guide takes a Laravel API from its first JSON response to a versioned, authenticated, filterable, authorized, tested, and documented service. The focus is not on isolated framework features, but on the decisions and complete implementations needed to create a stable API contract.
Tutorial scope: This is an educational, code-first guide to advanced Laravel API patterns. It teaches how the framework pieces fit together; it is not intended to prescribe one universal application architecture.
The guide is organized as a progressive technical reference. Each section explains one API design concern and provides the complete contents of every application file introduced or changed at that stage. The implementation uses Laravel 13 and PHP 8.3 so all examples remain internally consistent.
Laravel 13 note: Laravel 13 requires PHP 8.3 or newer. Its slim application skeleton registers API routes and exception rendering in
bootstrap/app.php; the legacyRouteServiceProvider,AuthServiceProvider, andapp/Exceptions/Handler.phppatterns have been replaced throughout this guide.
Tested with: Laravel 13.26.1, PHP 8.3+, Sanctum 4.3.3, and Scribe 5.11.0. These are the versions locked by the reference implementation.
Requirements and initial setup
- PHP 8.3 or newer, Composer, MySQL, and Git
- Basic familiarity with Laravel routes, controllers, Eloquent, migrations, and validation
- Postman or another HTTP client
composer create-project laravel/laravel:^13.0 orders-hub
cd orders-hub
php artisan install:api
php artisan migrate
Configure the orders_hub MySQL database in .env, then apply the displayed files section by section.
Laravel 13 ships with a minimal base controller. Because later sections use controller authorization helpers, enable the required framework traits from the start:
app/Http/Controllers/Controller.php
<?php
namespace App\Http\Controllers;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Foundation\Validation\ValidatesRequests;
abstract class Controller
{
use AuthorizesRequests, ValidatesRequests;
}
Guide roadmap
- Consistent JSON Responses and HTTP Status Codes
- Testing APIs with Postman
- Designing Resource-Oriented URLs
- Structuring a Versioned API
- Token Authentication with Laravel Sanctum
- Token Revocation and Secure Logout
- Designing Stable Response Payloads
- Conditional Fields and Relationships
- Optional Relationship Loading
- Reusable Query Filters
- Nested Resources and Relationship Filters
- Safe Client-Controlled Sorting
- Creating Resources with POST
- Deleting Resources with DELETE
- Full Resource Replacement with PUT
- Partial Resource Updates with PATCH
- Resource Authorization with Policies
- Access Control with Token Abilities
- Fine-Grained Field Permissions
- Customer-Owned Order Operations
- Secure User Management
- Applying the Principle of Least Privilege
- Consistent API Error Handling
- Generating API Documentation with Scribe
- Using One Response Format Everywhere
- Testing the Response Format
- Beyond the Tutorial Checklist
How to read this guide
Every section says what you get, generates the classes, shows the code, and ends with a request you can run.
New files are shown in full. Files you have already seen appear as a diff: + is an added line, - a removed one.
How to verify each checkpoint
php artisan migrate:fresh --seed
php artisan route:list --path=api
php artisan serve
Always send Accept: application/json. For protected routes, log in first and send the returned Sanctum token as Authorization: Bearer YOUR_TOKEN.
1. Consistent JSON Responses and HTTP Status Codes
We begin with a fresh Laravel application, add a simple API endpoint, and return a predictable JSON response with HTTP status 200 OK.
The implementation targets Laravel 13, PHP
^8.3, and Sanctum^4.3.
What we will build
A GET /api/login request will return:
{
"message": "Hello, Login!",
"status": 200
}
The response body is JSON and the actual HTTP response status is also 200.
1. Create the project
To follow the guide from a clean application, create a Laravel 13 project and install API routing with Sanctum:
composer create-project laravel/laravel:^13.0 orders-hub
cd orders-hub
php artisan install:api
The reference project configures MySQL with a database named orders_hub. Update your .env file with credentials appropriate for your machine:
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=orders_hub
DB_USERNAME=root
DB_PASSWORD=
This initial implementation does not query the database yet, so the endpoint works before migrations are needed.
2. Create the response trait
Generate the trait with Artisan:
php artisan make:trait Traits/ApiResponses
Keep the Traits/ prefix. Without it, make:trait drops the file straight into app/, because neither app/Concerns nor app/Traits exists yet in a new project.
Here is the complete file.
app/Traits/ApiResponses.php
<?php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponses
{
/**
* Return a successful response.
*/
protected function ok(string $message): JsonResponse
{
return $this->success($message, 200);
}
/**
* Return a success response whose payload status matches the HTTP status.
*/
protected function success(string $message, int $statusCode = 200): JsonResponse
{
return response()->json([
'message' => $message,
'status' => $statusCode
], $statusCode);
}
}
ok() is a convenient shortcut for a successful 200 response. It delegates to success(), which builds the JSON payload and passes the same status code to Laravel's response factory.
The second argument to response()->json() matters: without it, the body might say 200 while the HTTP response uses a different status. Keeping both values together makes the response consistent.
3. Create the authentication controller
Generate the controller:
php artisan make:controller AuthController
Replace its contents with the complete implementation.
app/Http/Controllers/AuthController.php
<?php
namespace App\Http\Controllers;
use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
class AuthController extends Controller
{
use ApiResponses;
/**
* Authenticate the user.
*/
public function login(): JsonResponse
{
return $this->ok('Hello, Login!');
}
}
The controller imports and uses ApiResponses, so all of the trait's protected helper methods become available inside the controller. The login() method declares a JsonResponse return type, which documents the contract of the action without any extra comment.
4. Register the API route
Here is the complete API routes file for this stage.
routes/api.php
<?php
use App\Http\Controllers\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are registered by bootstrap/app.php and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::get('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Routes declared here automatically receive Laravel's /api prefix. Therefore, Route::get('/login', ...) is available at /api/login.
The default protected /api/user route remains in the file, but it is not used at this stage.
5. Run and test the API
Start Laravel's development server:
php artisan serve
Test the endpoint in another terminal:
curl -i http://127.0.0.1:8000/api/login
The important parts of the response are:
HTTP/1.1 200 OK
Content-Type: application/json
{"message":"Hello, Login!","status":200}
You can also verify the route registration:
php artisan route:list --path=api/login
Complete implementation file tree
Only these application-specific files are added or changed at this stage; the remaining files are the standard Laravel 13 application skeleton.
app/
├── Http/
│ └── Controllers/
│ └── AuthController.php
└── Traits/
└── ApiResponses.php
routes/
└── api.php
Why use a response trait?
As an API grows, many controllers need to return the same response shapes. Centralizing those shapes prevents small inconsistencies and gives us one place to add other helpers later, such as created(), error(), or noContent().
For now, this gives us the foundation we need: an API endpoint, a predictable JSON payload, and an accurate HTTP 200 status.
2. Testing APIs with Postman
Login becomes a POST route and gets validated before the controller runs. Good data returns 200. A missing field returns 422 with the errors listed.
Generate the classes
php artisan make:request ApiLoginRequest
Requests go in app/Http/Requests, so the plain class name is enough.
The code
app/Http/Controllers/AuthController.php — changes
Type-hinting ApiLoginRequest is what triggers validation. register() is a stub for later.
namespace App\Http\Controllers;
use App\Http\Requests\ApiLoginRequest;
use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
/**
* Authenticate the user.
*/
public function login(): JsonResponse
public function login(ApiLoginRequest $request): JsonResponse
{
return $this->ok('Hello, Login!');
return $this->ok($request->get('email'));
}
/**
* Register a new user.
*/
public function register(): JsonResponse
{
return $this->ok('register');
}
}
app/Http/Requests/ApiLoginRequest.php
The rules live here instead of the controller. authorize() returns true because anyone may log in.
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class ApiLoginRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => 'required',
'password' => 'required'
];
}
}
routes/api.php — changes
Login moves from GET to POST. Credentials do not belong in a URL.
|
*/
Route::get('/login', [AuthController::class, 'login']);
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
Verify
curl -i -X POST http://127.0.0.1:8000/api/login \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"email":"test@example.com","password":"secret1234"}'
You get 200 and your email back. Send it without password and you get 422.
3. Designing Resource-Oriented URLs
The API starts returning real rows. One hundred orders across ten users, listed at GET /api/orders.
An order status is one of pending, paid, shipped or cancelled. The whole guide uses that set.
Generate the classes
php artisan make:model Order -mf
-m adds the migration and -f adds the factory. DatabaseSeeder already exists, so you edit it.
The code
app/Models/Order.php
$fillable is what lets Order::create() accept an array. The relationship arrives in section 8.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
protected $fillable = ['reference', 'status', 'notes', 'user_id'];
}
database/factories/OrderFactory.php
References look like ORD-12345. Status is one of the four words above.
<?php
namespace Database\Factories;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Order>
*/
class OrderFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'reference' => strtoupper(fake()->bothify('ord-#####')),
'notes' => fake()->paragraph(),
'status' => fake()->randomElement(['pending', 'paid', 'shipped', 'cancelled']),
];
}
}
database/migrations/2024_01_27_055742_create_orders_table.php
user_id is a foreign key. notes is free text.
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained();
$table->string('reference');
$table->text('notes');
$table->string('status');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('orders');
}
};
database/seeders/DatabaseSeeder.php
Ten users first, then one hundred orders spread across them.
<?php
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder;
class DatabaseSeeder extends Seeder
{
/**
* Seed the application's database.
*/
public function run(): void
{
$users = \App\Models\User::factory(10)->create();
\App\Models\Order::factory(100)
->recycle($users)
->create();
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
}
}
routes/api.php — changes
A temporary closure. Section 4 replaces it with a controller.
<?php
use App\Http\Controllers\AuthController;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::get('/orders', function() {
return Order::all();
});
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Verify
php artisan migrate:fresh --seed
curl -s http://127.0.0.1:8000/api/orders -H "Accept: application/json"
You get one hundred orders back as JSON.
4. Structuring a Versioned API
The same orders move to /api/v1/orders. All version one routes live in their own file, so adding a v2 later means adding a file.
Generate the classes
php artisan make:controller Api/V1/OrderController --api --model=Order
php artisan make:request Api/V1/StoreOrderRequest
php artisan make:request Api/V1/UpdateOrderRequest
The path prefix sets the namespace. Api/V1/OrderController becomes App\Http\Controllers\Api\V1\OrderController. --api skips the create and edit actions, which only HTML forms need.
The code
app/Http/Controllers/Api/V1/OrderController.php
Only index() has a body so far. The rest fill in from section 13.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
class OrderController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return Order::all();
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
//
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, Order $order)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Order $order)
{
//
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
Generated stubs. authorize() returns false, so writes answer 403 until policies arrive in section 17.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
app/Http/Requests/Api/V1/UpdateOrderRequest.php
The same stub. Both requests get real rules in section 13.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
bootstrap/app.php
shouldRenderJsonWhen() makes errors under api/* render as JSON instead of an HTML page.
<?php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
api: __DIR__.'/../routes/api.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware): void {
//
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
})->create();
routes/api.php — changes
The v1 group replaces the temporary /orders closure. Login and register stay where they are.
use App\Http\Controllers\AuthController;
use App\Models\Order;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::get('/orders', function() {
return Order::all();
});
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
routes/api_v1.php
apiResource registers index, store, show, update and destroy at once.
<?php
use App\Http\Controllers\Api\V1\OrderController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are registered by bootstrap/app.php and all of them will
| be assigned to the "api" middleware group. Make something great!
|
*/
Route::apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
Verify
php artisan route:list --path=api/v1
curl -s http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"
The list shows five orders routes under v1, and the request returns the seeded orders.
5. Token Authentication with Laravel Sanctum
Login returns a Sanctum token and the orders routes stop answering strangers. No token means 401.
Generate the classes
php artisan make:controller Api/AuthController
php artisan make:request Api/LoginUserRequest
Sanctum came with php artisan install:api in section 1.
The code
app/Models/User.php — changes
createToken() comes from Sanctum's HasApiTokens trait. Without it the model has no such method and login dies with a BadMethodCallException.
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['name', 'email', 'password'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasFactory, Notifiable;
use HasApiTokens, HasFactory, Notifiable;
app/Http/Controllers/Api/AuthController.php
Auth::attempt() checks the credentials, then createToken() issues the token.
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Traits\ApiResponses;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
use ApiResponses;
/**
* Authenticate the user and issue an API token.
*/
public function login(LoginUserRequest $request)
{
if (! Auth::attempt($request->only('email', 'password'))) {
return $this->error('Invalid credentials', 401);
}
$user = User::firstWhere('email', $request->email);
return $this->ok(
'Authenticated',
[
'token' => $user->createToken('API token for ' . $user->email)->plainTextToken
]
);
}
}
app/Http/Requests/Api/LoginUserRequest.php
Stricter than the old request: a real email and at least eight characters.
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class LoginUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
'email' => ['required', 'string', 'email'],
'password' => ['required', 'string', 'min:8'],
];
}
}
app/Traits/ApiResponses.php — changes
The whole file is shown for context, with the lines changed in this step marked. success() gains a data key for the token. error() returns failures in the same shape.
<?php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
trait ApiResponses
{
/**
* Return a successful response.
*/
protected function ok(string $message): JsonResponse
protected function ok(string $message, array $data): JsonResponse
{
return $this->success($message, 200);
return $this->success($message, $data, 200);
}
/**
* Return a success response whose payload status matches the HTTP status.
* Return a success response with the given payload.
*/
protected function success(string $message, int $statusCode = 200): JsonResponse
protected function success(string $message, array $data, int $statusCode = 200): JsonResponse
{
return response()->json([
'data' => $data,
'message' => $message,
'status' => $statusCode
], $statusCode);
}
/**
* Return an error response.
*/
protected function error(string $message, int $statusCode): JsonResponse
{
return response()->json([
'message' => $message,
'status' => $statusCode
], $statusCode);
}
}
routes/api.php — changes
Login now points at the new controller. The register stub from section 2 goes with the old controller — user creation gets its own section later, so leave no route pointing at a method that no longer exists.
<?php
use App\Http\Controllers\AuthController;
use App\Http\Controllers\Api\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Delete the superseded files
The stub controller and request from section 2 have been replaced by their Api\ counterparts.
rm app/Http/Controllers/AuthController.php
rm app/Http/Requests/ApiLoginRequest.php
routes/api_v1.php — changes
auth:sanctum is what turns the 401 on.
|
*/
Route::apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
Wrong credentials return 401, not 422. The payload was fine. The login failed.
Verify
php artisan tinker --execute="echo App\Models\User::first()->email;"
curl -s -X POST http://127.0.0.1:8000/api/login \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email":"SEEDED_EMAIL","password":"password"}'
curl -i http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"
Seeded users all use the password password. The token comes back under data.token. Without it the orders route returns 401. With -H "Authorization: Bearer YOUR_TOKEN" it works.
6. Token Revocation and Secure Logout
POST /api/logout deletes the token that made the call. Other devices stay signed in.
The code
app/Http/Controllers/Api/AuthController.php — changes
currentAccessToken() returns the token behind this request. Delete it and it is gone.
use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
return $this->ok(
'Authenticated',
[
'token' => $user->createToken('API token for ' . $user->email)->plainTextToken
'token' => $user->createToken(
'API token for ' . $user->email,
['*'],
now()->addMonth())->plainTextToken
]
);
}
/**
* Revoke the API token used for the current request.
*/
public function logout(Request $request)
{
$request->user()->currentAccessToken()->delete();
return $this->ok('');
}
}
app/Traits/ApiResponses.php — changes
$data now defaults to an empty array, so an empty response needs no argument.
/**
* Return a successful response.
*/
protected function ok(string $message, array $data): JsonResponse
protected function ok(string $message, array $data = []): JsonResponse
{
return $this->success($message, $data, 200);
}
/**
* Return a success response with the given payload.
*/
protected function success(string $message, array $data, int $statusCode = 200): JsonResponse
protected function success(string $message, array $data = [], int $statusCode = 200): JsonResponse
{
return response()->json([
'data' => $data,
routes/api.php — changes
Logout sits behind auth:sanctum. You cannot revoke a token without sending it.
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->post('/logout', [AuthController::class, 'logout']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
Tokens also expire after a month, set when they are issued.
Verify
curl -i -X POST http://127.0.0.1:8000/api/logout \
-H "Accept: application/json" \
-H "Authorization: Bearer YOUR_TOKEN"
curl -i http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" \
-H "Authorization: Bearer YOUR_TOKEN"
Logout returns 200. Reuse the same token and you get 401.
7. Designing Stable Response Payloads
Orders stop being raw table rows. Each one comes back as type, id, attributes, relationships and links, and the list is paginated.
Generate the classes
php artisan make:resource V1/OrderResource
Resources go in app/Http/Resources, so V1/ puts this one in App\Http\Resources\V1.
The code
app/Http/Controllers/Api/V1/OrderController.php — changes
index() paginates. show() returns one resource.
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
class OrderController extends Controller
{
*/
public function index()
{
return Order::all();
return OrderResource::collection(Order::paginate());
}
/**
*/
public function show(Order $order)
{
//
return new OrderResource($order);
}
/**
app/Http/Resources/V1/OrderResource.php
The resource decides the public field names. created_at goes out as createdAt.
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class OrderResource extends JsonResource
{
// public static $wrap = 'order';
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'type' => 'order',
'id' => $this->id,
'attributes' => [
'reference' => $this->reference,
'notes' => $this->notes,
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
],
'relationships' => [
'customer' => [
'data' => [
'type' => 'user',
'id' => $this->user_id
],
'links' => [
['self' => 'todo']
]
]
],
'links' => [
['self' => route('orders.show', ['order' => $this->id])]
]
];
}
}
Rename a column later and only this file changes.
Verify
curl -s "http://127.0.0.1:8000/api/v1/orders?page=1" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
Each item has type, id, attributes and a self link, plus links and meta for pagination.
8. Conditional Fields and Relationships
One resource class, different output per route. A single order shows its notes. The list hides them.
Generate the classes
php artisan make:controller Api/V1/UsersController --api --model=User
php artisan make:request Api/V1/StoreUserRequest
php artisan make:request Api/V1/UpdateUserRequest
php artisan make:resource V1/UserResource
The code
app/Http/Controllers/Api/V1/UsersController.php
The users side of the same pattern.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
class UsersController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return UserResource::collection(User::paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(User $user)
{
return new UserResource($user);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, User $user)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
//
}
}
app/Http/Requests/Api/V1/StoreUserRequest.php
A stub for now. The rules arrive in section 21.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
app/Http/Requests/Api/V1/UpdateUserRequest.php
The same stub for updates.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
];
}
}
app/Http/Resources/V1/OrderResource.php — changes
when() hides notes unless the route is orders.show.
'id' => $this->id,
'attributes' => [
'reference' => $this->reference,
'notes' => $this->notes,
'notes' => $this->when(
$request->routeIs('orders.show'),
$this->notes
),
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
]
]
],
'includes' => [
new UserResource($this->user)
],
'links' => [
['self' => route('orders.show', ['order' => $this->id])]
]
app/Http/Resources/V1/UserResource.php
mergeWhen() adds the timestamps only on users.* routes.
<?php
namespace App\Http\Resources\V1;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class UserResource extends JsonResource
{
/**
* Transform the resource into an array.
*
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'type' => 'user',
'id' => $this->id,
'attributes' => [
'name' => $this->name,
'email' => $this->email,
$this->mergeWhen($request->routeIs('users.*'), [
'emailVerifiedAt' => $this->email_verified_at,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
])
]
];
}
}
app/Models/Order.php — changes
Adds the user() relationship the order resource reads.
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Order extends Model
{
use HasFactory;
protected $fillable = ['reference', 'status', 'notes', 'user_id'];
/**
* Get the user that placed the order.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}
routes/api_v1.php — changes
Registers the users resource behind auth:sanctum.
<?php
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\UsersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
*/
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('users', UsersController::class);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
routes/api.php — changes
Section 4 copied the /user closure into routes/api_v1.php instead of moving it, so GET /api/user and GET /api/v1/user have been answering identically ever since. Now that the versioned side owns the whole users surface, drop the unversioned twin. Request goes with it — nothing else in this file uses it.
use App\Http\Controllers\Api\AuthController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::middleware('auth:sanctum')->post('/logout', [AuthController::class, 'logout']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
api.php is now just the token endpoints plus the v1 mount. Everything a client reads or writes lives behind a version.
One class per model beats one class per endpoint. Rename a field once and both routes follow.
Verify
curl -s http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -s http://127.0.0.1:8000/api/v1/orders/1 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -i http://127.0.0.1:8000/api/user \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The list has no notes key. The single order does. /api/v1/users/1 shows the extra user timestamps. The old /api/user now returns 404 — use /api/v1/user.
9. Optional Relationship Loading
?include=customer embeds the customer in each order. Without it, no extra query runs.
Generate the classes
php artisan make:controller Api/V1/ApiController
A plain controller, not a resource one. It only holds shared helpers.
The code
app/Http/Controllers/Api/V1/ApiController.php
include() reads the query parameter and answers yes or no.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
class ApiController extends Controller
{
/**
* Determine whether the given relationship was requested via the include parameter.
*/
public function include(string $relationship) : bool
{
$param = request()->get('include');
if (!isset($param)) {
return false;
}
$includeValues = explode(',', strtolower($param));
return in_array(strtolower($relationship), $includeValues);
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
Eager loads with with() only when the client asked.
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
class OrderController extends Controller
class OrderController extends ApiController
{
/**
* Display a listing of the resource.
*/
public function index()
{
if ($this->include('customer')) {
return OrderResource::collection(Order::with('user')->paginate());
}
return OrderResource::collection(Order::paginate());
}
*/
public function show(Order $order)
{
if ($this->include('customer')) {
return new OrderResource($order->load('user'));
}
return new OrderResource($order);
}
app/Http/Controllers/Api/V1/UsersController.php — changes
Same for users and their orders.
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
class UsersController extends Controller
class UsersController extends ApiController
{
/**
* Display a listing of the resource.
*/
public function index()
{
if ($this->include('orders')) {
return UserResource::collection(User::with('orders')->paginate());
}
return UserResource::collection(User::paginate());
}
*/
public function show(User $user)
{
if ($this->include('orders')) {
return new UserResource($user->load('orders'));
}
return new UserResource($user);
}
app/Http/Resources/V1/OrderResource.php — changes
whenLoaded() keeps includes out of the payload unless the relation is loaded.
'id' => $this->user_id
],
'links' => [
['self' => 'todo']
'self' => route('users.show', ['user' => $this->user_id])
]
]
],
'includes' => [
new UserResource($this->user)
],
'includes' => new UserResource($this->whenLoaded('user')),
'links' => [
['self' => route('orders.show', ['order' => $this->id])]
'self' => route('orders.show', ['order' => $this->id])
]
];
}
app/Http/Resources/V1/UserResource.php — changes
Adds includes for loaded orders and a self link.
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
])
],
'includes' => OrderResource::collection($this->whenLoaded('orders')),
'links' => [
'self' => route('users.show', ['user' => $this->id])
]
];
}
app/Models/User.php — changes
Adds the orders() relationship, the other half of the belongsTo added to Order in section 8.
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
/**
* Get the orders placed by the user.
*/
public function orders() : HasMany
{
return $this->hasMany(Order::class);
}
}
Without whenLoaded() a page of orders would fire one query per row. Count them and the difference is stark: a page of 15 orders costs 2 queries without include, 3 with it. Section 8's unconditional includes cost 17.
Verify
curl -s "http://127.0.0.1:8000/api/v1/orders?include=customer" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -s "http://127.0.0.1:8000/api/v1/orders" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The first response has an includes block. The second has none.
10. Reusable Query Filters
Filtering from the query string: ?filter[status]=pending, a date range, or ?filter[reference]=ORD-1*.
Generate the classes
php artisan make:class Http/Filters/V1/QueryFilter
php artisan make:class Http/Filters/V1/OrderFilter
Filters are not a Laravel concept, so there is no generator for them. make:class creates a plain class at the path you give it.
The code
app/Http/Controllers/Api/V1/OrderController.php — changes
index() type-hints OrderFilter. Laravel resolves it with the request already inside.
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
/**
* Display a listing of the resource.
*/
public function index()
public function index(OrderFilter $filters)
{
if ($this->include('customer')) {
return OrderResource::collection(Order::with('user')->paginate());
}
return OrderResource::collection(Order::paginate());
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
public function show(Order $order)
{
if ($this->include('customer')) {
return new OrderResource($order->load('user'));
return new OrderResource($order->load('customer'));
}
return new OrderResource($order);
app/Http/Filters/V1/QueryFilter.php
apply() calls the method that matches each query key. No method, no effect.
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter
{
protected $builder;
protected $request;
/**
* Create a new filter instance for the current request.
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Apply the given filters to the builder.
*/
protected function filter($arr)
{
foreach($arr as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
}
}
return $this->builder;
}
/**
* Apply the request query parameters to the given builder.
*/
public function apply(Builder $builder)
{
$this->builder = $builder;
foreach($this->request->all() as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
}
}
return $builder;
}
}
app/Http/Filters/V1/OrderFilter.php
One method per supported filter. * becomes a SQL wildcard in reference.
include() is the one method that takes a relationship name rather than a column value, so it checks each name with isRelation() first. Hand with() a name the model does not define and Eloquent throws Call to undefined relationship, which reaches the client as a 500 — an unhandled crash triggered by nothing more than a mistyped query parameter. Filtering the list keeps an unknown name behaving like every other unrecognised parameter: ignored.
<?php
namespace App\Http\Filters\V1;
class OrderFilter extends QueryFilter
{
/**
* Filter by creation date, or by a date range when two dates are given.
*/
public function createdAt($value)
{
$dates = explode(',', $value);
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
}
/**
* Eager load the requested relationships, ignoring any the model does not define.
*/
public function include($value)
{
if (!is_string($value)) {
return $this->builder;
}
$model = $this->builder->getModel();
$relations = array_filter(
explode(',', $value),
fn ($relation) => $model->isRelation($relation)
);
return $this->builder->with($relations);
}
/**
* Filter by one or more statuses.
*/
public function status($value)
{
return $this->builder->whereIn('status', explode(',', $value));
}
/**
* Filter by reference, where * acts as a wildcard.
*/
public function reference($value)
{
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('reference', 'like', $likeStr);
}
/**
* Filter by update date, or by a date range when two dates are given.
*/
public function updatedAt($value)
{
$dates = explode(',', $value);
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
}
}
app/Http/Resources/V1/OrderResource.php — changes
The relationship is called customer now, so whenLoaded() follows.
]
]
],
'includes' => new UserResource($this->whenLoaded('user')),
'includes' => new UserResource($this->whenLoaded('customer')),
'links' => [
'self' => route('orders.show', ['order' => $this->id])
]
app/Models/Order.php — changes
The filter scope lets you write Order::filter($filters). The relationship is renamed to customer in the same pass, so the public vocabulary matches the payload — the column stays user_id, which is why the rename has to name it explicitly.
<?php
namespace App\Models;
use App\Http\Filters\V1\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Order extends Model
{
/** @use HasFactory<\Database\Factories\OrderFactory> */
use HasFactory;
protected $fillable = ['reference', 'status', 'notes', 'user_id'];
/**
* Get the user that placed the order.
* Get the customer that placed the order.
*/
public function user(): BelongsTo
public function customer(): BelongsTo
{
return $this->belongsTo(User::class);
return $this->belongsTo(User::class, 'user_id');
}
/**
* Apply the given query filters to the builder.
*/
public function scopeFilter(Builder $builder, QueryFilter $filters)
{
return $filters->apply($builder);
}
}
The filter class is the allow list. A parameter with no method does nothing, which is the safe way to fail.
Verify
curl -s "http://127.0.0.1:8000/api/v1/orders?filter[status]=pending" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -s "http://127.0.0.1:8000/api/v1/orders?filter[reference]=ORD-1*" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The first call returns pending orders only. The second returns every reference starting with ORD-1.
11. Nested Resources and Relationship Filters
Two new URLs: /api/v1/customers and /api/v1/customers/{customer}/orders.
Generate the classes
php artisan make:controller Api/V1/CustomersController --api --model=User
php artisan make:controller Api/V1/CustomerOrdersController
The nested controller does not need --api. It starts with index alone, and the remaining verbs arrive as later sections need them: store in section 13, destroy in section 14, replace in section 15, and update in section 16. Register only index at this checkpoint; exposing resource routes before their controller methods exist would turn valid-looking requests into 500 responses.
The code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
Filters by user_id, then hands the query to the same OrderFilter.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
class CustomerOrdersController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index($customer_id, OrderFilter $filters)
{
return OrderResource::collection(
Order::where('user_id', $customer_id)->filter($filters)->paginate()
);
}
}
app/Http/Controllers/Api/V1/CustomersController.php
Users seen as customers, with ?include=orders.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
class CustomersController extends ApiController
{
/**
* Display a listing of the resource.
*/
public function index()
{
if ($this->include('orders')) {
return UserResource::collection(User::with('orders')->paginate());
}
return UserResource::collection(User::paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(User $customer)
{
if ($this->include('orders')) {
return new UserResource($customer->load('orders'));
}
return new UserResource($customer);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, User $user)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
//
}
}
app/Http/Resources/V1/OrderResource.php — changes
Relationship and self links now point at real routes.
'id' => $this->user_id
],
'links' => [
'self' => route('users.show', ['user' => $this->user_id])
'self' => route('customers.show', ['customer' => $this->user_id])
]
]
],
app/Http/Resources/V1/UserResource.php — changes
Fields and links now follow the customers.* routes.
'attributes' => [
'name' => $this->name,
'email' => $this->email,
$this->mergeWhen($request->routeIs('users.*'), [
$this->mergeWhen($request->routeIs('customers.*'), [
'emailVerifiedAt' => $this->email_verified_at,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
],
'includes' => OrderResource::collection($this->whenLoaded('orders')),
'links' => [
'self' => route('users.show', ['user' => $this->id])
'self' => route('customers.show', ['customer' => $this->id])
]
];
}
routes/api_v1.php — changes
Registers customers and the nested customers.orders.
<?php
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\UsersController;
use App\Http\Controllers\Api\V1\CustomersController;
use App\Http\Controllers\Api\V1\CustomerOrdersController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
*/
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('users', UsersController::class);
Route::middleware('auth:sanctum')->apiResource('customers', CustomersController::class);
Route::middleware('auth:sanctum')->apiResource('customers.orders', CustomerOrdersController::class)->only('index');
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
Delete the superseded file
customers replaces the users resource in the route file, which leaves UsersController with nothing pointing at it. Section 21 introduces a separate UserController for user administration rather than reviving this one, so it is dead from here on.
rm app/Http/Controllers/Api/V1/UsersController.php
UserResource stays — CustomersController returns it, and it is the reason the same customer payload appears under both names.
The order payload is the same on both routes, so a client can parse either one the same way.
Verify
php artisan route:list --path=api/v1/customers
curl -s "http://127.0.0.1:8000/api/v1/customers/1/orders?filter[status]=pending" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -s http://127.0.0.1:8000/api/v1/customers/1 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The route list shows the nested routes, and the request returns that customer's pending orders. Every self link now points at /api/v1/customers/..., and /api/v1/users is gone.
12. Safe Client-Controlled Sorting
Sorting from the query string: ?sort=status, ?sort=-createdAt for descending, or both at once.
Generate the classes
php artisan make:class Http/Filters/V1/CustomerFilter
The code
app/Http/Controllers/Api/V1/CustomersController.php — changes
index() takes a CustomerFilter, so customers filter and sort like orders.
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\CustomerFilter;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
/**
* Display a listing of the resource.
*/
public function index()
public function index(CustomerFilter $filters)
{
if ($this->include('orders')) {
return UserResource::collection(User::with('orders')->paginate());
}
return UserResource::collection(User::paginate());
return UserResource::collection(User::filter($filters)->paginate());
}
/**
app/Http/Filters/V1/CustomerFilter.php
Lists what customers may be sorted by. include() guards the relationship name exactly as OrderFilter does in section 10 — the same unchecked with() would turn ?include=bogus into a 500 here too.
<?php
namespace App\Http\Filters\V1;
class CustomerFilter extends QueryFilter
{
protected $sortable = [
'name',
'email',
'createdAt' => 'created_at',
'updatedAt' => 'updated_at'
];
/**
* Filter by creation date, or by a date range when two dates are given.
*/
public function createdAt($value)
{
$dates = explode(',', $value);
if (count($dates) > 1) {
return $this->builder->whereBetween('created_at', $dates);
}
return $this->builder->whereDate('created_at', $value);
}
/**
* Eager load the requested relationships, ignoring any the model does not define.
*/
public function include($value)
{
if (!is_string($value)) {
return $this->builder;
}
$model = $this->builder->getModel();
$relations = array_filter(
explode(',', $value),
fn ($relation) => $model->isRelation($relation)
);
return $this->builder->with($relations);
}
/**
* Filter by one or more identifiers.
*/
public function id($value)
{
return $this->builder->whereIn('id', explode(',', $value));
}
/**
* Filter by email, where * acts as a wildcard.
*/
public function email($value)
{
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('email', 'like', $likeStr);
}
/**
* Filter by name, where * acts as a wildcard.
*/
public function name($value)
{
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('name', 'like', $likeStr);
}
/**
* Filter by update date, or by a date range when two dates are given.
*/
public function updatedAt($value)
{
$dates = explode(',', $value);
if (count($dates) > 1) {
return $this->builder->whereBetween('updated_at', $dates);
}
return $this->builder->whereDate('updated_at', $value);
}
}
app/Http/Filters/V1/QueryFilter.php — changes
sort() checks each key against $sortable before it reaches orderBy(). A leading - means descending.
{
protected $builder;
protected $request;
protected $sortable = [];
/**
* Create a new filter instance for the current request.
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* Apply the request query parameters to the given builder.
*/
public function apply(Builder $builder)
{
$this->builder = $builder;
foreach($this->request->all() as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
}
}
return $builder;
}
/**
}
/**
* Apply the request query parameters to the given builder.
* Sort the query by the requested columns.
*/
public function apply(Builder $builder)
protected function sort($value)
{
$this->builder = $builder;
$sortAttributes = explode(',', $value);
foreach($this->request->all() as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
foreach($sortAttributes as $sortAttribute) {
$direction = 'asc';
if (strpos($sortAttribute, '-') === 0) {
$direction = 'desc';
$sortAttribute = substr($sortAttribute, 1);
}
if (!in_array($sortAttribute, $this->sortable) && !array_key_exists($sortAttribute, $this->sortable)) {
continue;
}
$columnName = $this->sortable[$sortAttribute] ?? null;
if ($columnName === null) {
$columnName = $sortAttribute;
}
$this->builder->orderBy($columnName, $direction);
}
return $builder;
}
}
app/Http/Filters/V1/OrderFilter.php — changes
Same list for orders. The map also translates createdAt to created_at.
class OrderFilter extends QueryFilter
{
protected $sortable = [
'reference',
'status',
'createdAt' => 'created_at',
'updatedAt' => 'updated_at'
];
/**
* Filter by creation date, or by a date range when two dates are given.
*/
app/Models/User.php — changes
Adds the filter scope to User, the same scope Order gained in section 10.
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Http\Filters\V1\QueryFilter;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
{
return $this->hasMany(Order::class);
}
/**
* Apply the given query filters to the builder.
*/
public function scopeFilter(Builder $builder, QueryFilter $filters)
{
return $filters->apply($builder);
}
}
Passing the raw key to orderBy() would let anyone sort by any column. The list is the point.
Verify
curl -s "http://127.0.0.1:8000/api/v1/orders?sort=-createdAt,status" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -s "http://127.0.0.1:8000/api/v1/orders?sort=password" \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The first call sorts newest first, then by status. The second is ignored, because password is not in the list.
13. Creating Resources with POST
POST /api/v1/orders takes the same document shape the API returns and creates an order.
The code
app/Http/Controllers/Api/V1/ApiController.php — changes
Pulls in ApiResponses, so child controllers can call error().
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
class ApiController extends Controller
{
use ApiResponses;
/**
* Determine whether the given relationship was requested via the include parameter.
*/
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
The nested version takes user_id from the URL.
use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
Order::where('user_id', $customer_id)->filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store($customer_id, StoreOrderRequest $request)
{
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $customer_id
];
return new OrderResource(Order::create($model));
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
Checks the customer exists, then maps the request onto columns.
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
*/
public function store(StoreOrderRequest $request)
{
//
try {
User::findOrFail($request->input('data.relationships.customer.data.id'));
} catch (ModelNotFoundException $exception) {
return $this->error('The provided customer id does not exist.', 404);
}
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
return new OrderResource(Order::create($model));
}
/**
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
status must be pending, paid, shipped or cancelled. The customer id is required only on orders.store, because the nested route has it in the URL.
*/
public function authorize(): bool
{
return false;
return true;
}
/**
*/
public function rules(): array
{
$rules = [
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
];
if ($this->routeIs('orders.store')) {
$rules['data.relationships.customer.data.id'] = 'required|integer';
}
return $rules;
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages()
{
return [
//
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
];
}
}
Reading an order, changing a field and posting it back needs no translation between formats.
Expand the nested resource registration at the same time as the controller. This keeps every registered route callable:
Route::middleware('auth:sanctum')->apiResource(
'customers.orders',
CustomerOrdersController::class,
)->only(['index', 'store']);
Verify
curl -s -X POST http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{
"data": {
"attributes": {
"reference": "ORD-90001",
"notes": "Created from the guide",
"status": "pending"
},
"relationships": { "customer": { "data": { "id": 1 } } }
}
}'
You get the new order back. Send "status": "unknown" and you get 422. Send a customer id of 99999 and you get 404.
14. Deleting Resources with DELETE
DELETE /api/v1/orders/{id} removes an order. A missing one returns JSON, not an HTML error page.
Once the nested destroy() method shown below exists, add its route to the checkpoint as well:
Route::middleware('auth:sanctum')->apiResource(
'customers.orders',
CustomerOrdersController::class,
)->only(['index', 'store', 'destroy']);
The code
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
Same for the nested route.
Extending ApiController is what puts ok() and error() within reach — the trait arrived there in section 13.
That also settles a loose end from the previous section. store() takes the customer id straight from the URL and hands it to Order::create(), so a nonexistent id reached the database and came back as 500 SQLSTATE[23000]: Integrity constraint violation. The top-level store() already guarded against exactly that; now the nested one can answer the same way.
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CustomerOrdersController extends Controller
class CustomerOrdersController extends ApiController
{
/**
* Display a listing of the resource.
public function store($customer_id, StoreOrderRequest $request)
{
try {
User::findOrFail($customer_id);
} catch (ModelNotFoundException $exception) {
return $this->error('The provided customer id does not exist.', 404);
}
$model = [
return new OrderResource(Order::create($model));
}
/**
* Remove the specified resource from storage.
*/
public function destroy($customer_id, $order_id)
{
try {
$order = Order::findOrFail($order_id);
if ($order->user_id == $customer_id) {
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->error('Order cannot be found.', 404);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
findOrFail() inside a try lets the controller answer 404 in its own format. That is why these methods now take an id instead of a bound model.
/**
* Display the specified resource.
*/
public function show(Order $order)
public function show($order_id)
{
if ($this->include('customer')) {
return new OrderResource($order->load('customer'));
try {
$order = Order::findOrFail($order_id);
if ($this->include('customer')) {
return new OrderResource($order->load('customer'));
}
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
return new OrderResource($order);
}
/**
/**
* Remove the specified resource from storage.
*/
public function destroy(Order $order)
public function destroy($order_id)
{
//
try {
$order = Order::findOrFail($order_id);
$order->delete();
return $this->ok('Order successfully deleted');
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
}
Section 23 moves this handling to one place, so the controllers get their try blocks removed again.
Verify
curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -i -X DELETE http://127.0.0.1:8000/api/v1/customers/9999/orders/2 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
The first call deletes the order. The second returns 404 with Order cannot be found. The third returns 404 too — the nested route only deletes an order when the id in the URL is the one that owns it, so pointing the wrong customer at a real order reads as missing rather than deleting it.
15. Full Resource Replacement with PUT
PUT replaces the whole order. Leave a field out and the request fails.
Generate the classes
php artisan make:request Api/V1/ReplaceOrderRequest
The code
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
The nested route gets replace() too. Scope the lookup to both URL parameters from the start, just as destroy() does. An order reached through the wrong customer URL must be indistinguishable from a missing order and return 404; it must never fall through to an empty 200.
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
$order->update($model);
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($customer_id, $order_id)
app/Http/Controllers/Api/V1/OrderController.php — changes
replace() overwrites every column and returns the updated resource.
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, Order $order)
public function update(UpdateOrderRequest $request, $order_id)
{
//
// PATCH
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceOrderRequest $request, $order_id)
{
// PUT
try {
$order = Order::findOrFail($order_id);
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
$order->update($model);
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
app/Http/Requests/Api/V1/ReplaceOrderRequest.php
The store rules with everything marked required. That is the only difference.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class ReplaceOrderRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
'data.relationships.customer.data.id' => 'required|integer',
];
return $rules;
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages()
{
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
];
}
}
app/Http/Resources/V1/OrderResource.php — changes
notes now shows everywhere except the two listings.
'attributes' => [
'reference' => $this->reference,
'notes' => $this->when(
$request->routeIs('orders.show'),
!$request->routeIs(['orders.index', 'customers.orders.index']),
$this->notes
),
'status' => $this->status,
routes/api_v1.php — changes
Laravel maps PUT and PATCH to update by default. Dropping update and adding an explicit Route::put splits them.
|
*/
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->apiResource('customers', CustomersController::class);
Route::middleware('auth:sanctum')->apiResource('customers.orders', CustomerOrdersController::class);
Route::middleware('auth:sanctum')->group(function() {
Route::apiResource('orders', OrderController::class)->except(['update']);
Route::put('orders/{order}', [OrderController::class, 'replace']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
Route::apiResource('customers', CustomersController::class);
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::get('/user', function (Request $request) {
return $request->user();
});
});
Verify
curl -i -X PUT http://127.0.0.1:8000/api/v1/orders/2 \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"data":{"attributes":{"reference":"ORD-90002","notes":"Replaced","status":"paid"},"relationships":{"customer":{"data":{"id":1}}}}}'
php artisan route:list --path=api/v1/orders
The full body returns the replaced order. Remove notes and you get 422.
The route list is worth a look. PUT|PATCH … orders.update is gone, replaced by a PUT line pointing at replace. Nothing answers PATCH yet, so it returns 405 until the next section adds it back — update() exists but is still an empty stub, and no route reaches it.
16. Partial Resource Updates with PATCH
PATCH changes only the fields you send. Everything else stays as it was.
Generate the classes
php artisan make:request Api/V1/BaseOrderRequest
The code
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
The same move to mappedAttributes(), plus update(). The nested customer id still comes from the URL, so merge it into the mapped data atomically. Dropping that value while refactoring would leave user_id unset and turn a working nested POST into a database 500.
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use Illuminate\Database\Eloquent\ModelNotFoundException;
*/
public function store($customer_id, StoreOrderRequest $request)
{
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $customer_id
];
return new OrderResource(Order::create($model));
return new OrderResource(Order::create(
$request->mappedAttributes() + ['user_id' => $customer_id]
));
}
/**
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
$order->update($model);
$order->update($request->mappedAttributes());
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $customer_id, $order_id)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
$order->update($request->mappedAttributes());
return new OrderResource($order);
Like replace(), update() scopes the lookup to both ids. Patching an order through the wrong customer's URL therefore returns 404 at this checkpoint.
app/Http/Controllers/Api/V1/OrderController.php — changes
update() passes mappedAttributes() straight to the model.
return $this->error('The provided customer id does not exist.', 404);
}
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
return new OrderResource(Order::create($model));
return new OrderResource(Order::create($request->mappedAttributes()));
}
/**
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
try {
$order = Order::findOrFail($order_id);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
try {
$order = Order::findOrFail($order_id);
$model = [
'reference' => $request->input('data.attributes.reference'),
'notes' => $request->input('data.attributes.notes'),
'status' => $request->input('data.attributes.status'),
'user_id' => $request->input('data.relationships.customer.data.id')
];
$order->update($model);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
app/Http/Requests/Api/V1/BaseOrderRequest.php
mappedAttributes() keeps only the keys the request actually contains. That is what makes a partial update work.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseOrderRequest extends FormRequest
{
/**
* Map the request attributes to their database columns.
*/
public function mappedAttributes()
{
$attributeMap = [
'data.attributes.reference' => 'reference',
'data.attributes.notes' => 'notes',
'data.attributes.status' => 'status',
'data.attributes.createdAt' => 'created_at',
'data.attributes.updatedAt' => 'updated_at',
'data.relationships.customer.data.id' => 'user_id',
];
$attributesToUpdate = [];
foreach ($attributeMap as $key => $attribute) {
if ($this->has($key)) {
$attributesToUpdate[$attribute] = $this->input($key);
}
}
return $attributesToUpdate;
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages()
{
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
];
}
}
app/Http/Requests/Api/V1/ReplaceOrderRequest.php — changes
Extends BaseOrderRequest now, so the messages come from the parent.
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class ReplaceOrderRequest extends FormRequest
class ReplaceOrderRequest extends BaseOrderRequest
{
/**
* Determine if the user is authorized to make this request.
return $rules;
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages()
{
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
];
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
Same parent, same shared messages.
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends FormRequest
class StoreOrderRequest extends BaseOrderRequest
{
/**
* Determine if the user is authorized to make this request.
return $rules;
}
/**
* Get the error messages for the defined validation rules.
*/
public function messages()
{
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use pending, paid, shipped, or cancelled.'
];
}
}
app/Http/Requests/Api/V1/UpdateOrderRequest.php
Every rule is sometimes, so a missing field is skipped instead of rejected.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends FormRequest
class UpdateOrderRequest extends BaseOrderRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
$rules = [
'data.attributes.reference' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:pending,paid,shipped,cancelled',
'data.relationships.customer.data.id' => 'sometimes|integer',
];
return $rules;
}
}
routes/api_v1.php — changes
An explicit Route::patch beside the PUT route from the last section.
Route::middleware('auth:sanctum')->group(function() {
Route::apiResource('orders', OrderController::class)->except(['update']);
Route::put('orders/{order}', [OrderController::class, 'replace']);
Route::patch('orders/{order}', [OrderController::class, 'update']);
Route::apiResource('customers', CustomersController::class);
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
Route::get('/user', function (Request $request) {
return $request->user();
Sharing the rules through one parent keeps PUT and PATCH in step as fields are added.
Verify
curl -s -X PATCH http://127.0.0.1:8000/api/v1/orders/2 \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" \
-d '{"data":{"attributes":{"status":"shipped"}}}'
The status changes and reference and notes stay put. The same body sent with PUT would fail.
17. Resource Authorization with Policies
Laravel 13 note: Laravel 13 also supports controller authorization attributes. This guide keeps policy checks explicit so the authorization flow and its gradual application remain visible while learning.
An order can now be updated only by the customer who owns it. Anyone else gets 403.
Generate the classes
php artisan make:policy V1/OrderPolicy --model=Order
The code
app/Http/Controllers/Api/V1/ApiController.php — changes
isAble() wraps authorize() and passes the versioned policy class along.
class ApiController extends Controller
{
use ApiResponses;
protected $policyClass;
/**
* Determine whether the given relationship was requested via the include parameter.
return in_array(strtolower($relationship), $includeValues);
}
/**
* Determine whether the current token grants the given ability.
*/
public function isAble($ability, $targetModel)
{
return $this->authorize($ability, [$targetModel, $this->policyClass]);
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
update() calls the policy and turns AuthorizationException into a 403 in the API format.
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
*/
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('update', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
app/Policies/V1/OrderPolicy.php
Compares the user against the order's user_id.
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
class OrderPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
/**
* Determine whether the user can update the order.
*/
public function update(User $user, Order $order)
{
// TODO check for token ability
return $user->id === $order->user_id;
}
}
app/Providers/AppServiceProvider.php
The policy sits in a V1 namespace, so Laravel will not find it by convention. Gate::policy() wires it up.
<?php
namespace App\Providers;
use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
//
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
}
}
Validation asks whether the data is well formed. A policy asks whether this user may touch this row.
Only update() is guarded so far. replace(), destroy() and every method on the nested controller still run for anyone holding a valid token, so at this checkpoint PUT and DELETE on somebody else's order both succeed. Section 18 adds the same check to store, replace and delete; section 20 does the nested controller. Do not read this section as having locked the resource down — it has locked down one verb.
Verify
curl -i -X PATCH http://127.0.0.1:8000/api/v1/orders/1 \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer TOKEN_OF_ANOTHER_CUSTOMER" \
-d '{"data":{"attributes":{"status":"shipped"}}}'
curl -i -X DELETE http://127.0.0.1:8000/api/v1/orders/1 \
-H "Accept: application/json" \
-H "Authorization: Bearer TOKEN_OF_ANOTHER_CUSTOMER"
Another customer's token gets 403. The owner's token gets the updated order. The DELETE still returns 200 for anyone — that is the gap section 18 closes.
Comment out the Gate::policy() line and try again with the owner's token: the request fails with 403 as well. Laravel looks for App\Policies\OrderPolicy by convention, and this one lives in App\Policies\V1, so without the registration no policy is found and the gate denies.
18. Access Control with Token Abilities
A manager's token can act on any order. A customer's token can only touch their own. Same endpoints, different answers.
Generate the classes
php artisan make:class Permissions/V1/Abilities
is_manager goes into the existing users migration, so run php artisan migrate:fresh --seed afterwards.
The code
app/Http/Controllers/Api/AuthController.php — changes
createToken() now receives the ability list instead of ['*'].
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginUserRequest;
use App\Models\User;
use App\Permissions\V1\Abilities;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
[
'token' => $user->createToken(
'API token for ' . $user->email,
['*'],
Abilities::getAbilities($user),
now()->addMonth())->plainTextToken
]
);
app/Http/Controllers/Api/V1/OrderController.php — changes
store() goes through the policy as well.
{
try {
User::findOrFail($request->input('data.relationships.customer.data.id'));
// policy
$this->isAble('store', Order::class);
return new OrderResource(Order::create($request->mappedAttributes()));
} catch (ModelNotFoundException $exception) {
return $this->error('The provided customer id does not exist.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
return new OrderResource(Order::create($request->mappedAttributes()));
}
/**
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('replace', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
{
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('delete', $order);
$order->delete();
return $this->ok('Order successfully deleted');
app/Permissions/V1/Abilities.php
Every ability as a constant, plus the list each kind of user gets.
<?php
namespace App\Permissions\V1;
use App\Models\User;
final class Abilities
{
public const CreateOrder = 'order:create';
public const UpdateOrder = 'order:update';
public const ReplaceOrder = 'order:replace';
public const DeleteOrder = 'order:delete';
public const UpdateOwnOrder = 'order:own:update';
public const DeleteOwnOrder = 'order:own:delete';
public const CreateUser = 'user:create';
public const UpdateUser = 'user:update';
public const ReplaceUser = 'user:replace';
public const DeleteUser = 'user:delete';
/**
* Get the token abilities granted to the given user.
*/
public static function getAbilities(User $user)
{
if ($user->is_manager) {
return [
self::CreateOrder,
self::UpdateOrder,
self::ReplaceOrder,
self::DeleteOrder,
self::CreateUser,
self::UpdateUser,
self::ReplaceUser,
self::DeleteUser,
];
} else {
return [
self::CreateOrder,
self::UpdateOwnOrder,
self::DeleteOwnOrder
];
}
}
}
app/Policies/V1/OrderPolicy.php — changes
tokenCan() decides. A broad ability grants the action. An own ability grants it only on your own rows.
use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;
class OrderPolicy
{
}
/**
* Determine whether the user can delete the order.
*/
public function delete(User $user, Order $order)
{
if ($user->tokenCan(Abilities::DeleteOrder)) {
return true;
} else if ($user->tokenCan(Abilities::DeleteOwnOrder)) {
return $user->id === $order->user_id;
}
return false;
}
/**
* Determine whether the user can replace the order.
*/
public function replace(User $user, Order $order)
{
if ($user->tokenCan(Abilities::ReplaceOrder)) {
return true;
}
return false;
}
/**
* Determine whether the user can create the order.
*/
public function store(User $user)
{
if ($user->tokenCan(Abilities::CreateOrder)) {
return true;
}
return false;
}
/**
* Determine whether the user can update the order.
*/
public function update(User $user, Order $order)
{
// TODO check for token ability
return $user->id === $order->user_id;
if ($user->tokenCan(Abilities::UpdateOrder)) {
return true;
} else if ($user->tokenCan(Abilities::UpdateOwnOrder)) {
return $user->id === $order->user_id;
}
return false;
}
}
database/migrations/0001_01_01_000000_create_users_table.php — changes
Adds the is_manager flag. One line, inside the existing users block — leave the rest of the file alone, because this migration also creates password_reset_tokens and sessions.
Schema::create('users', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->unique();
$table->timestamp('email_verified_at')->nullable();
$table->string('password');
$table->boolean('is_manager')->default(false);
$table->rememberToken();
$table->timestamps();
});
database/seeders/DatabaseSeeder.php — changes
Seeds manager@manager.com with the password password.
->recycle($users)
->create();
User::create([
'email' => 'manager@manager.com',
'password' => bcrypt('password'),
'name' => 'The Manager',
'is_manager' => true
]);
// User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
is_manager is not in the model's #[Fillable] list, and it does not need to be: db:seed wraps the run in Model::unguarded(), so mass assignment protection is off while seeding. Set the same flag through User::create() in ordinary application code and it would be dropped without a word.
Abilities live inside the token, so changing a role means issuing a new token. The policy code stays put.
Verify
php artisan migrate:fresh --seed
curl -s -X POST http://127.0.0.1:8000/api/login \
-H "Accept: application/json" -H "Content-Type: application/json" \
-d '{"email":"manager@manager.com","password":"password"}'
As the manager, a PATCH on any order works. As a seeded customer, someone else's order returns 403.
The full picture at this checkpoint, and it is worth checking each one:
| Action | Manager | Customer, own order | Customer, another's |
|---|---|---|---|
POST /orders | 201 | 201 | — |
PATCH /orders/{id} | 200 | 200 | 403 |
PUT /orders/{id} | 200 | 403 | 403 |
DELETE /orders/{id} | 200 | 200 | 403 |
A customer cannot PUT even their own order, because replace() accepts only the broad ReplaceOrder ability and no customer is granted it. That is deliberate — a full replacement includes the customer id, so it is a manager's tool. The nested customers.orders routes are still unguarded; section 20 gets to them.
19. Fine-Grained Field Permissions
A customer may create an order, but only for themselves. The check happens during validation, not after the insert.
The code
app/Http/Controllers/Api/V1/OrderController.php — changes
store() asks the policy first and returns 403 when it says no.
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
public function store(StoreOrderRequest $request)
{
try {
User::findOrFail($request->input('data.relationships.customer.data.id'));
// policy
// policy
$this->isAble('store', Order::class);
return new OrderResource(Order::create($request->mappedAttributes()));
} catch (ModelNotFoundException $exception) {
return $this->error('The provided customer id does not exist.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
}
The exists:users,id rule in the request now covers what User::findOrFail() used to, so the lookup and its ModelNotFoundException catch both go. That changes the answer for an unknown customer id from 404 to 422 — a validation failure, which is what it always was.
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
With only the own ability, the customer id rule gains size: with the caller's id. Any other value fails.
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
class StoreOrderRequest extends BaseOrderRequest
{
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
'data.relationships.customer.data.id' => 'required|integer|exists:users,id'
];
$user = $this->user();
if ($this->routeIs('orders.store')) {
$rules['data.relationships.customer.data.id'] = 'required|integer';
if ($user->tokenCan(Abilities::CreateOwnOrder)) {
$rules['data.relationships.customer.data.id'] .= '|size:' . $user->id;
}
}
return $rules;
app/Http/Requests/Api/V1/UpdateOrderRequest.php — changes
A customer cannot move an order to someone else, so the field is prohibited for them.
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
class UpdateOrderRequest extends BaseOrderRequest
{
'data.relationships.customer.data.id' => 'sometimes|integer',
];
if ($this->user()->tokenCan(Abilities::UpdateOwnOrder)) {
$rules['data.relationships.customer.data.id'] = 'prohibited';
}
return $rules;
}
}
app/Permissions/V1/Abilities.php — changes
Adds CreateOwnOrder, so creating for yourself is a separate permission.
public const ReplaceOrder = 'order:replace';
public const DeleteOrder = 'order:delete';
public const CreateOwnOrder = 'order:own:create';
public const UpdateOwnOrder = 'order:own:update';
public const DeleteOwnOrder = 'order:own:delete';
*/
public static function getAbilities(User $user)
{
// don't assign '*'
if ($user->is_manager) {
return [
self::CreateOrder,
];
} else {
return [
self::CreateOrder,
self::CreateOwnOrder,
self::UpdateOwnOrder,
self::DeleteOwnOrder
];
app/Policies/V1/OrderPolicy.php — changes
store() accepts either the broad or the own ability.
*/
public function store(User $user)
{
if ($user->tokenCan(Abilities::CreateOrder)) {
return true;
}
return false;
return $user->tokenCan(Abilities::CreateOrder) ||
$user->tokenCan(Abilities::CreateOwnOrder);
}
/**
A policy authorises the action. It cannot judge the value of one field, so that check belongs in the request class.
Verify
curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer CUSTOMER_TOKEN" \
-d '{"data":{"attributes":{"reference":"ORD-90003","notes":"Mine","status":"pending"},"relationships":{"customer":{"data":{"id":999}}}}}'
With a customer token and someone else's id you get 422. With your own id the order is created.
POST /orders with customer id | Manager | Customer |
|---|---|---|
| their own id | 201 | 201 |
| another customer's id | 201 | 422 |
| an id that does not exist | 422 | 422 |
The last row is the behaviour change: section 13 answered 404 there, and from here it is 422.
PATCH gained a matching rule. A customer sending data.relationships.customer.data.id gets 422 whatever the value, because prohibited rejects the field's presence rather than its contents — they may edit their order, not hand it to someone else. A manager sending the same field reassigns the order and gets 200.
20. Customer-Owned Order Operations
The nested customer routes now gain the same policy checks as the top-level routes while preserving the customer-scoped lookups introduced earlier.
The code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
Lookups continue to use both ids, so another customer's order reads as 404 instead of being edited through the wrong URL. Section 20 adds authorization without weakening that ownership boundary.
The whole file is shown, so note two things that change. The User::findOrFail($customer_id) guard from section 14 is no longer needed: prepareForValidation() puts the route's customer into the payload and exists:users,id validates it, so an unknown customer is a 422 before the controller runs — the same trade section 19 made at the top level. The temporary array merge from section 16 can now become the reusable mapping 'customer' => 'user_id'; both forms keep the nested endpoint working, while this one centralizes the translation in the FormRequest.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
*/
public function index($customer_id, OrderFilter $filters)
{
return OrderResource::collection(
Order::where('user_id', $customer_id)->filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store($customer_id, StoreOrderRequest $request)
public function store(StoreOrderRequest $request, $customer_id)
{
return new OrderResource(Order::create($request->mappedAttributes()));
try {
// policy
$this->isAble('store', Order::class);
return new OrderResource(Order::create($request->mappedAttributes([
'customer' => 'user_id'
])));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
$this->isAble('replace', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $customer_id, $order_id)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
$this->isAble('update', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($customer_id, $order_id)
{
try {
$order = Order::findOrFail($order_id);
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
if ($order->user_id == $customer_id) {
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->error('Order cannot be found.', 404);
$this->isAble('delete', $order);
$order->delete();
return $this->ok('Order successfully deleted');
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to delete that resource', 403);
}
}
}
app/Http/Requests/Api/V1/BaseOrderRequest.php — changes
mappedAttributes() now accepts extra mappings, which turns the nested customer key into user_id.
/**
* Map the request attributes to their database columns.
*/
public function mappedAttributes()
public function mappedAttributes(array $otherAttributes = [])
{
$attributeMap = [
$attributeMap = array_merge([
'data.attributes.reference' => 'reference',
'data.attributes.notes' => 'notes',
'data.attributes.status' => 'status',
'data.attributes.createdAt' => 'created_at',
'data.attributes.updatedAt' => 'updated_at',
'data.relationships.customer.data.id' => 'user_id',
];
], $otherAttributes);
$attributesToUpdate = [];
foreach ($attributeMap as $key => $attribute) {
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
prepareForValidation() copies the customer from the route into the payload, so one rule set covers both routes.
*/
public function rules(): array
{
$customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';
$rules = [
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
'data.relationships.customer.data.id' => 'required|integer|exists:users,id'
$customerIdAttr => 'required|integer|exists:users,id'
];
$user = $this->user();
if ($this->routeIs('orders.store')) {
if ($user->tokenCan(Abilities::CreateOwnOrder)) {
$rules['data.relationships.customer.data.id'] .= '|size:' . $user->id;
}
if ($user->tokenCan(Abilities::CreateOwnOrder)) {
$rules[$customerIdAttr] .= '|size:' . $user->id;
}
return $rules;
}
/**
* Prepare the data for validation.
*/
protected function prepareForValidation()
{
if ($this->routeIs('customers.orders.store')) {
$this->merge([
'customer' => $this->route('customer')
]);
}
}
}
app/Policies/V1/OrderPolicy.php — changes
replace() collapses to the single expression it always was. Only the broad ReplaceOrder ability grants it, so a customer cannot replace an order even through their own nested URL.
*/
public function replace(User $user, Order $order)
{
if ($user->tokenCan(Abilities::ReplaceOrder)) {
return true;
}
return false;
return $user->tokenCan(Abilities::ReplaceOrder);
}
/**
Verify
curl -i -X PUT http://127.0.0.1:8000/api/v1/customers/1/orders/5 \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer MANAGER_TOKEN" \
-d '{"data":{"attributes":{"reference":"ORD-90004","notes":"Nested replace","status":"paid"},"relationships":{"customer":{"data":{"id":1}}}}}'
curl -i -X POST http://127.0.0.1:8000/api/v1/customers/1/orders \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer MANAGER_TOKEN" \
-d '{"data":{"attributes":{"reference":"ORD-90005","notes":"Nested create","status":"pending"}}}'
If order 5 does not belong to customer 1 you get 404, even though the order exists. Keep the customer relationship in that PUT body: ReplaceOrderRequest still requires it, and validation runs before the controller, so leaving it out gets you a 422 rather than the 404 you were looking for.
The POST is the one to watch. It carries no customer id in the request body, takes it from the URL, and returns 201.
21. Secure User Management
A full users resource for tokens that carry the user abilities. customers drops to read only.
Generate the classes
php artisan make:controller Api/V1/UserController --api --model=User
php artisan make:request Api/V1/BaseUserRequest
php artisan make:request Api/V1/ReplaceUserRequest
php artisan make:policy V1/UserPolicy --model=User
StoreUserRequest and UpdateUserRequest already exist from section 8.
The code
app/Http/Controllers/Api/V1/CustomersController.php — changes
Now index and show only, and a customer is a user who has placed at least one order.
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(User::filter($filters)->paginate());
return UserResource::collection(
User::has('orders')->filter($filters)->paginate()
);
}
/**
has('orders') rather than a join. Joining orders and calling distinct() looks equivalent and is not: the paginator's count query runs over the joined rows, so meta.total reports the number of orders instead of the number of customers. With 116 orders across 10 customers the endpoint claims 8 pages, page 1 holds all 10 rows, and pages 2 through 8 come back empty. has() constrains with a subquery, leaves one row per user, and counts what the client is actually paging through.
app/Http/Controllers/Api/V1/UserController.php
The user CRUD controller, with a policy check on every action.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\CustomerFilter;
use App\Http\Requests\Api\V1\ReplaceUserRequest;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class UserController extends ApiController
{
protected $policyClass = UserPolicy::class;
/**
* Display a listing of the resource.
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(
User::filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
try {
// policy
$this->isAble('store', User::class);
return new UserResource(User::create($request->mappedAttributes()));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
}
/**
* Display the specified resource.
*/
public function show(User $user)
{
if ($this->include('orders')) {
return new UserResource($user->load('orders'));
}
return new UserResource($user);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, $user_id)
{
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('update', $user);
$user->update($request->mappedAttributes());
return new UserResource($user);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceUserRequest $request, $user_id)
{
// PUT
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('replace', $user);
$user->update($request->mappedAttributes());
return new UserResource($user);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($user_id)
{
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('delete', $user);
$user->delete();
return $this->ok('User successfully deleted');
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to delete that resource', 403);
}
}
}
replace() and destroy() each need the AuthorizationException catch as much as update() does. Without it the exception escapes the controller, and while Laravel still renders a 403, it does so as {"message": "This action is unauthorized."} — the framework's shape, not the message/status envelope every other error in this API uses.
app/Http/Requests/Api/V1/BaseUserRequest.php
mappedAttributes() runs bcrypt() on the password, so a plain value never reaches the table.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseUserRequest extends FormRequest
{
/**
* Map the request attributes to their database columns.
*/
public function mappedAttributes(array $otherAttributes = [])
{
$attributeMap = array_merge([
'data.attributes.name' => 'name',
'data.attributes.email' => 'email',
'data.attributes.isManager' => 'is_manager',
'data.attributes.password' => 'password',
], $otherAttributes);
$attributesToUpdate = [];
foreach ($attributeMap as $key => $attribute) {
if ($this->has($key)) {
$value = $this->input($key);
if ($attribute === 'password') {
$value = bcrypt($value);
}
$attributesToUpdate[$attribute] = $value;
}
}
return $attributesToUpdate;
}
}
app/Http/Requests/Api/V1/ReplaceUserRequest.php
Every field required, like the order replace request.
<?php
namespace App\Http\Requests\Api\V1;
class ReplaceUserRequest extends BaseUserRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
$rules = [
'data.attributes.name' => 'required|string',
'data.attributes.email' => 'required|email',
'data.attributes.isManager' => 'required|boolean',
'data.attributes.password' => 'required|string',
];
return $rules;
}
}
app/Http/Requests/Api/V1/StoreUserRequest.php
The whole file, with this step's changes marked.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest extends FormRequest
class StoreUserRequest extends BaseUserRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
'data.attributes.name' => 'required|string',
'data.attributes.email' => 'required|email',
'data.attributes.isManager' => 'required|boolean',
'data.attributes.password' => 'required|string',
];
}
}
app/Http/Requests/Api/V1/UpdateUserRequest.php
The whole file, with this step's changes marked.
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserRequest extends FormRequest
class UpdateUserRequest extends BaseUserRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return false;
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
*/
public function rules(): array
{
return [
//
'data.attributes.name' => 'sometimes|string',
'data.attributes.email' => 'sometimes|email',
'data.attributes.isManager' => 'sometimes|boolean',
'data.attributes.password' => 'sometimes|string',
];
}
}
app/Http/Resources/V1/UserResource.php — changes
Adds isManager to the payload.
'attributes' => [
'name' => $this->name,
'email' => $this->email,
'isManager' => $this->is_manager,
$this->mergeWhen($request->routeIs('customers.*'), [
'emailVerifiedAt' => $this->email_verified_at,
'createdAt' => $this->created_at,
app/Models/User.php — changes
is_manager becomes fillable and is cast to a boolean. password was already cast as hashed.
#[Fillable(['name', 'email', 'password'])]
#[Fillable(['name', 'email', 'password', 'is_manager'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_manager' => 'boolean',
];
}
Until now is_manager only ever reached the table through the seeder, which runs unguarded. POST /api/v1/users goes through User::create() in ordinary request handling, so without the #[Fillable] entry the flag would be dropped and every user created through the API would come out a non-manager.
app/Policies/V1/UserPolicy.php
Who may create, update, replace or delete a user.
<?php
namespace App\Policies\V1;
use App\Models\User;
use App\Permissions\V1\Abilities;
class UserPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
/**
* Determine whether the user can delete the given user account.
*/
public function delete(User $user, User $model)
{
return $user->tokenCan(Abilities::DeleteUser);
}
/**
* Determine whether the user can replace the given user account.
*/
public function replace(User $user, User $model)
{
return $user->tokenCan(Abilities::ReplaceUser);
}
/**
* Determine whether the user can create the given user account.
*/
public function store(User $user)
{
return $user->tokenCan(Abilities::CreateUser);
}
/**
* Determine whether the user can update the given user account.
*/
public function update(User $user, User $model)
{
return $user->tokenCan(Abilities::UpdateUser);
}
}
app/Providers/AppServiceProvider.php — changes
Registers the user policy beside the order one.
namespace App\Providers;
use App\Models\Order;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use App\Policies\V1\UserPolicy;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\ServiceProvider;
public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
Gate::policy(User::class, UserPolicy::class);
}
}
routes/api_v1.php — changes
users gets the same PUT and PATCH split as orders.
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\CustomersController;
use App\Http\Controllers\Api\V1\CustomerOrdersController;
use App\Http\Controllers\Api\V1\UserController;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
Route::put('orders/{order}', [OrderController::class, 'replace']);
Route::patch('orders/{order}', [OrderController::class, 'update']);
Route::apiResource('customers', CustomersController::class);
Route::apiResource('users', UserController::class)->except(['update']);
Route::put('users/{user}', [UserController::class, 'replace']);
Route::patch('users/{user}', [UserController::class, 'update']);
Route::apiResource('customers', CustomersController::class)->except(['store','update','destroy']);
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
Customers and users are one table seen two ways. customers is what an order client reads. users is the admin view.
Verify
php artisan route:list --path=api/v1/users
curl -i -X POST http://127.0.0.1:8000/api/v1/users \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer MANAGER_TOKEN" \
-d '{"data":{"attributes":{"name":"New User","email":"new@example.com","isManager":false,"password":"password"}}}'
The manager's token creates the user and the response carries no password — #[Hidden] on the model keeps it out, and bcrypt() in mappedAttributes() means the stored value is a hash. Send "isManager": true and the created user really is a manager; that is what the new #[Fillable] entry buys.
A customer token gets 403 on POST, PATCH, PUT and DELETE, each in the API's own error shape. GET is a different matter: any authenticated token can still read /api/v1/users, including the isManager flag of every account. Section 22 is where that gets tightened.
customers is read-only from here: POST /api/v1/customers and DELETE /api/v1/customers/{id} both answer 405.
22. Applying the Principle of Least Privilege
Same endpoints, tighter defaults. A refused action returns a clean 403, and a customer's writes are pinned to their own records.
The code
app/Http/Controllers/Api/V1/ApiController.php — changes
isAble() returns true or false instead of throwing.
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Auth\Access\AuthorizationException;
class ApiController extends Controller
{
*/
public function isAble($ability, $targetModel)
{
return $this->authorize($ability, [$targetModel, $this->policyClass]);
try {
$this->authorize($ability, [$targetModel, $this->policyClass]);
return true;
} catch (AuthorizationException $ex) {
return false;
}
}
}
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
The try blocks give way to if ($this->isAble(...)).
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CustomerOrdersController extends ApiController
*/
public function store(StoreOrderRequest $request, $customer_id)
{
try {
// policy
$this->isAble('store', Order::class);
if ($this->isAble('store', Order::class)) {
return new OrderResource(Order::create($request->mappedAttributes([
'customer' => 'user_id'
])));
}
return new OrderResource(Order::create($request->mappedAttributes([
'customer' => 'user_id'
])));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
return $this->error('You are not authorized to create that resource', 403);
}
/**
->where('user_id', $customer_id)
->firstOrFail();
$this->isAble('replace', $order);
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
$order->update($request->mappedAttributes());
return new OrderResource($order);
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
->where('user_id', $customer_id)
->firstOrFail();
$this->isAble('update', $order);
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
$order->update($request->mappedAttributes());
return new OrderResource($order);
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
->where('user_id', $customer_id)
->firstOrFail();
$this->isAble('delete', $order);
if ($this->isAble('delete', $order)) {
$order->delete();
return $this->ok('Order successfully deleted');
}
$order->delete();
return $this->ok('Order successfully deleted');
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to delete that resource', 403);
}
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
The same rewrite in the top level controller.
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Policies\V1\OrderPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
*/
public function store(StoreOrderRequest $request)
{
try {
// policy
$this->isAble('store', Order::class);
if ($this->isAble('store', Order::class)) {
return new OrderResource(Order::create($request->mappedAttributes()));
}
return new OrderResource(Order::create($request->mappedAttributes()));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
return $this->error('You are not authorized to create that resource', 403);
}
/**
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('update', $order);
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return new OrderResource($order);
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
$order = Order::findOrFail($order_id);
// policy
$this->isAble('replace', $order);
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
$order->update($request->mappedAttributes());
return $this->error('You are not authorized to update that resource', 403);
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
$order = Order::findOrFail($order_id);
// policy
$this->isAble('delete', $order);
if ($this->isAble('delete', $order)) {
$order->delete();
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->ok('Order successfully deleted');
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
app/Http/Controllers/Api/V1/UserController.php — changes
Every action is now if ($this->isAble(...)), which removes the repeated try blocks.
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class UserController extends ApiController
*/
public function store(StoreUserRequest $request)
{
try {
// policy
$this->isAble('store', User::class);
if ($this->isAble('store', User::class)) {
return new UserResource(User::create($request->mappedAttributes()));
}
return new UserResource(User::create($request->mappedAttributes()));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to create that resource', 403);
}
return $this->error('You are not authorized to create that resource', 403);
}
/**
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('update', $user);
if ($this->isAble('update', $user)) {
$user->update($request->mappedAttributes());
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return new UserResource($user);
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('replace', $user);
if ($this->isAble('replace', $user)) {
$user->update($request->mappedAttributes());
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return new UserResource($user);
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
}
try {
$user = User::findOrFail($user_id);
// policy
$this->isAble('delete', $user);
if ($this->isAble('delete', $user)) {
$user->delete();
$user->delete();
return $this->ok('User successfully deleted');
}
return $this->ok('User successfully deleted');
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
}
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
The customer id starts pinned to the caller. Only a token with order:create widens it.
public function rules(): array
{
$customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';
$user = $this->user();
$customerRule = 'required|integer|exists:users,id';
$rules = [
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
$customerIdAttr => 'required|integer|exists:users,id'
$customerIdAttr => $customerRule . '|size:' . $user->id
];
$user = $this->user();
if ($user->tokenCan(Abilities::CreateOwnOrder)) {
$rules[$customerIdAttr] .= '|size:' . $user->id;
if ($user->tokenCan(Abilities::CreateOrder)) {
$rules[$customerIdAttr] = $customerRule;
}
return $rules;
app/Http/Requests/Api/V1/UpdateOrderRequest.php — changes
The customer id is prohibited unless the token carries order:update.
'data.attributes.reference' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:pending,paid,shipped,cancelled',
'data.relationships.customer.data.id' => 'sometimes|integer',
'data.relationships.customer.data.id' => 'prohibited',
];
if ($this->user()->tokenCan(Abilities::UpdateOwnOrder)) {
$rules['data.relationships.customer.data.id'] = 'prohibited';
if ($this->user()->tokenCan(Abilities::UpdateOrder)) {
$rules['data.relationships.customer.data.id'] = 'sometimes|integer';
}
return $rules;
app/Permissions/V1/Abilities.php — changes
The final ability list. No token ever gets *.
*/
public static function getAbilities(User $user)
{
// don't assign '*'
if ($user->is_manager) {
return [
self::CreateOrder,
Start from the narrow rule and widen it for privileged tokens. A forgotten check then fails closed.
Verify
curl -i -X DELETE http://127.0.0.1:8000/api/v1/users/2 \
-H "Accept: application/json" -H "Authorization: Bearer CUSTOMER_TOKEN"
curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer CUSTOMER_TOKEN" \
-d '{"data":{"attributes":{"reference":"ORD-90006","notes":"Mine","status":"pending"},"relationships":{"customer":{"data":{"id":3}}}}}'
A customer token gets 403. The manager's token performs the delete.
The visible responses barely move — the same requests were already refused in section 19. What changed is where the refusal comes from. isAble() no longer throws, so every controller answers through $this->error() and each 403 arrives in the message/status envelope rather than escaping as Laravel's own {"message": "This action is unauthorized."}.
The rest is defence in depth, and it shows on a token that carries neither order ability:
php artisan tinker --execute="echo App\Models\User::find(2)->createToken('bare', ['user:create'])->plainTextToken;"
Post an order with that token naming customer 7 and the request fails at validation with 422, because the customer id now defaults to the caller's own and only order:create widens it. Name your own id instead and validation passes, then the policy refuses with 403. Under the previous rules the field carried no pin unless the token held order:own:create, so a token with neither ability had a wide-open rule and only the policy standing behind it.
23. Consistent API Error Handling
One error shape for the whole API. Validation failures, missing records and expired tokens all come back the same way.
The code
bootstrap/app.php — changes
Renderers for ValidationException, ModelNotFoundException, NotFoundHttpException and AuthenticationException. Each returns early for anything outside api/*, so the web side keeps Laravel's behaviour.
<?php
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
$exceptions->shouldRenderJsonWhen(
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
);
$exceptions->render(function (ValidationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
$errors = collect($exception->errors())
->flatMap(fn (array $messages, string $field) =>
collect($messages)->map(fn (string $message) => [
'status' => 422,
'message' => $message,
'source' => $field,
])
)->values()->all();
return response()->json(['errors' => $errors], 422);
});
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json(['errors' => [[
'status' => 404,
'message' => 'The resource cannot be found.',
'source' => $exception->getModel(),
]]], 404);
});
$exceptions->render(function (NotFoundHttpException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
$previous = $exception->getPrevious();
return response()->json(['errors' => [[
'status' => 404,
'message' => 'The resource cannot be found.',
'source' => $previous instanceof ModelNotFoundException
? $previous->getModel()
: '',
]]], 404);
});
$exceptions->render(function (AuthenticationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json(['errors' => [[
'status' => 401,
'message' => 'Unauthenticated.',
'source' => '',
]]], 401);
});
})->create();
The NotFoundHttpException renderer is the one that actually does the work, and the ModelNotFoundException one alone is not enough. Laravel's handler converts a ModelNotFoundException into a NotFoundHttpException before the render callbacks are consulted, so a renderer registered only for the former never fires. That matters more after this section than before it: every findOrFail() and its try block is about to be replaced by route model binding, which means binding failures become the only way a 404 occurs. Register just the first renderer and every missing record answers with Laravel's own {"message": "No query results for model [App\\Models\\Order] 999999"} — and, with APP_DEBUG on, a full stack trace.
Catching NotFoundHttpException also picks up any unmatched URL under api/*, so a typo'd endpoint returns the same envelope instead of an HTML page. The original exception survives as getPrevious(), which is where source still comes from when the cause was a model lookup.
routes/api_v1.php — changes
Route model binding replaces the manual lookups below, and the nested routes need scopeBindings() to keep the ownership check section 20 added.
Route::apiResource('customers', CustomersController::class)->except(['store','update','destroy']);
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
Route::scopeBindings()->group(function () {
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['show', 'update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::patch('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'update']);
});
Without this, the refactor below quietly undoes section 20. Order::where('id', $order_id)->where('user_id', $customer_id)->firstOrFail() matched on both ids; a bare Order $order binding matches on the order id alone, so PATCH /customers/3/orders/{order-owned-by-2} finds the order through the wrong customer's URL and a manager's token edits it. scopeBindings() tells Laravel to resolve {order} through $customer->orders(), which restores the 404. Note it goes on a group — PendingResourceRegistration has no scopeBindings() method, so chaining it onto apiResource(...) throws a BadMethodCallException.
app/Http/Controllers/Api/V1/CustomerOrdersController.php
The whole file, with this step's changes marked.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Models\Order;
use App\Models\User;
use App\Policies\V1\OrderPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
*/
public function index($customer_id, OrderFilter $filters)
public function index(User $customer, OrderFilter $filters)
{
return OrderResource::collection(
Order::where('user_id', $customer_id)->filter($filters)->paginate()
Order::where('user_id', $customer->id)->filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request, $customer_id)
public function store(StoreOrderRequest $request, User $customer)
{
if ($this->isAble('store', Order::class)) {
return new OrderResource(Order::create($request->mappedAttributes([
'customer' => 'user_id'
])));
}
return $this->error('You are not authorized to create that resource', 403);
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id)
public function replace(ReplaceOrderRequest $request, User $customer, Order $order)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $customer_id, $order_id)
public function update(UpdateOrderRequest $request, User $customer, Order $order)
{
// PUT
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Remove the specified resource from storage.
*/
public function destroy($customer_id, $order_id)
public function destroy(User $customer, Order $order)
{
try {
$order = Order::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
if ($this->isAble('delete', $order)) {
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
if ($this->isAble('delete', $order)) {
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->notAuthorized('You are not authorized to delete that resource');
}
}
app/Http/Controllers/Api/V1/OrderController.php
Route model binding comes back and the try blocks from section 14 go away.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\OrderFilter;
use App\Http\Requests\Api\V1\ReplaceOrderRequest;
use App\Models\Order;
use App\Http\Requests\Api\V1\StoreOrderRequest;
use App\Http\Requests\Api\V1\UpdateOrderRequest;
use App\Http\Resources\V1\OrderResource;
use App\Policies\V1\OrderPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
*/
public function index(OrderFilter $filters)
{
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
if ($this->isAble('store', Order::class)) {
return new OrderResource(Order::create($request->mappedAttributes()));
}
return $this->error('You are not authorized to create that resource', 403);
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Display the specified resource.
*/
public function show($order_id)
public function show(Order $order)
{
try {
$order = Order::findOrFail($order_id);
if ($this->include('customer')) {
return new OrderResource($order->load('customer'));
}
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
if ($this->include('customer')) {
return new OrderResource($order->load('customer'));
}
return new OrderResource($order);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
public function update(UpdateOrderRequest $request, Order $order)
{
// PATCH
try {
$order = Order::findOrFail($order_id);
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceOrderRequest $request, $order_id)
public function replace(ReplaceOrderRequest $request, Order $order)
{
// PUT
try {
$order = Order::findOrFail($order_id);
// policy
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Remove the specified resource from storage.
*/
public function destroy($order_id)
public function destroy(Order $order)
{
try {
$order = Order::findOrFail($order_id);
// policy
if ($this->isAble('delete', $order)) {
$order->delete();
// policy
if ($this->isAble('delete', $order)) {
$order->delete();
return $this->ok('Order successfully deleted');
}
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
return $this->ok('Order successfully deleted');
}
return $this->notAuthorized('You are not authorized to delete that resource');
}
}
app/Http/Controllers/Api/V1/UserController.php
The whole file, with this step's changes marked.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\CustomerFilter;
use App\Http\Requests\Api\V1\ReplaceUserRequest;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
use App\Policies\V1\UserPolicy;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class UserController extends ApiController
{
protected $policyClass = UserPolicy::class;
/**
* Display a listing of the resource.
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(
User::filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
if ($this->isAble('store', User::class)) {
return new UserResource(User::create($request->mappedAttributes()));
}
return $this->error('You are not authorized to create that resource', 403);
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Display the specified resource.
*/
public function show(User $user)
{
if ($this->include('orders')) {
return new UserResource($user->load('orders'));
}
return new UserResource($user);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, $user_id)
public function update(UpdateUserRequest $request, User $user)
{
try {
$user = User::findOrFail($user_id);
if ($this->isAble('update', $user)) {
$user->update($request->mappedAttributes());
if ($this->isAble('update', $user)) {
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
return new UserResource($user);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Replace the specified resource in storage.
*/
public function replace(ReplaceUserRequest $request, $user_id)
public function replace(ReplaceUserRequest $request, User $user)
{
// PUT
try {
$user = User::findOrFail($user_id);
if ($this->isAble('replace', $user)) {
$user->update($request->mappedAttributes());
if ($this->isAble('replace', $user)) {
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return $this->error('You are not authorized to update that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
return new UserResource($user);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Remove the specified resource from storage.
*/
public function destroy($user_id)
public function destroy(User $user)
{
try {
$user = User::findOrFail($user_id);
if ($this->isAble('delete', $user)) {
$user->delete();
if ($this->isAble('delete', $user)) {
$user->delete();
return $this->ok('User successfully deleted');
}
return $this->error('You are not authorized to delete that resource', 403);
} catch (ModelNotFoundException $exception) {
return $this->error('User cannot be found.', 404);
return $this->ok('User successfully deleted');
}
return $this->notAuthorized('You are not authorized to delete that resource');
}
}
app/Traits/ApiResponses.php — changes
error() takes a message or a list of errors. notAuthorized() builds the 403 entry.
/**
* Return an error response.
*/
protected function error(string $message, int $statusCode): JsonResponse
protected function error(array|string $errors = [], ?int $statusCode = null): JsonResponse
{
if (is_string($errors)) {
return response()->json([
'message' => $errors,
'status' => $statusCode
], $statusCode);
}
return response()->json([
'message' => $message,
'status' => $statusCode
'errors' => $errors
], $statusCode);
}
/**
* Return a 403 response for a forbidden request.
*/
protected function notAuthorized(string $message): JsonResponse
{
return $this->error([[
'status' => 403,
'message' => $message,
'source' => ''
]], 403);
}
}
Validation errors are flattened, so a request breaking three rules produces three entries.
Verify
curl -i http://127.0.0.1:8000/api/v1/orders/999999 \
-H "Accept: application/json" -H "Authorization: Bearer YOUR_TOKEN"
curl -i -X POST http://127.0.0.1:8000/api/v1/orders \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer YOUR_TOKEN" -d '{"data":{"attributes":{}}}'
curl -i http://127.0.0.1:8000/api/v1/orders -H "Accept: application/json"
curl -i -X PATCH http://127.0.0.1:8000/api/v1/customers/3/orders/ORDER_OWNED_BY_2 \
-H "Accept: application/json" -H "Content-Type: application/json" \
-H "Authorization: Bearer MANAGER_TOKEN" \
-d '{"data":{"attributes":{"status":"paid"}}}'
Three requests, one envelope: 404 for the missing order, 422 with one entry per failed rule, 401 without a token. The fourth is the scoping check — it must be 404, not 200. If it returns 200, scopeBindings() is missing and the order was just edited through the wrong customer's URL.
Every shape now matches:
| Case | Status | Body |
|---|---|---|
| missing order | 404 | errors[0].source is App\Models\Order |
unknown api/* URL | 404 | errors[0].source is empty |
| no token | 401 | Unauthenticated. |
| refused by policy | 403 | the notAuthorized() entry |
| invalid body | 422 | one entry per failed rule |
24. Generating API Documentation with Scribe
Scribe reads the docblocks and builds browsable documentation, an OpenAPI file and a Postman collection.
The code
app/Http/Controllers/Api/AuthController.php — changes
@unauthenticated marks login as open. @response shows a real example.
use ApiResponses;
/**
* Authenticate the user and issue an API token.
* Login
*
* Authenticates the user and returns the user's API token.
*
* @unauthenticated
* @group Authentication
* @response 200 {
"data": {
"token": "{YOUR_AUTH_KEY}"
},
"message": "Authenticated",
"status": 200
}
*/
public function login(LoginUserRequest $request)
{
}
/**
* Revoke the API token used for the current request.
* Logout
*
* Signs out the user and destroys the API token.
*
* @group Authentication
* @response 200 {}
*/
public function logout(Request $request)
{
app/Http/Controllers/Api/V1/CustomerOrdersController.php — changes
Docblocks become Scribe annotations: @group, @urlParam, @response.
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
* Get all orders
*
* Retrieves all orders created by a specific user.
*
* @group Managing Orders by Customer
*
* @urlParam customer integer required The customer's ID. No-example
*
* @response 200 {"data":[{"type":"user","id":3,"attributes":{"name":"Mr. Henri Beatty MD","email":"bmertz@example.net","isManager":false,"emailVerifiedAt":"2024-03-14T04:41:51.000000Z","createdAt":"2024-03-14T04:41:51.000000Z","updatedAt":"2024-03-14T04:41:51.000000Z"},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/3"}}],"links":{"first":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","last":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","prev":null,"next":null},"meta":{"current_page":1,"from":1,"last_page":1,"links":[{"url":null,"label":"« Previous","active":false},{"url":"http:\/\/localhost:8000\/api\/v1\/customers?page=1","label":"1","active":true},{"url":null,"label":"Next »","active":false}],"path":"http:\/\/localhost:8000\/api\/v1\/customers","per_page":15,"to":1,"total":10}}
*
* @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=name
* @queryParam filter[name] Filter by name. Wildcards are supported.
* @queryParam filter[email] Filter by email. Wildcards are supported.
*/
public function index(User $customer, OrderFilter $filters)
{
}
/**
* Store a newly created resource in storage.
* Create an order
*
* Creates an order for the specified customer.
*
* @group Managing Orders by Customer
*
* @urlParam customer integer required The customer's ID. No-example
*
*/
public function store(StoreOrderRequest $request, User $customer)
{
}
/**
* Replace the specified resource in storage.
* Replace a customer's order
*
* Replaces a customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer integer required The customer's ID. No-example
* @urlParam order integer required The order ID. No-example
* @response {"data":{"type":"order","id":107,"attributes":{"reference":"ORD-10432","notes":"Priority delivery","status":"paid","createdAt":"2024-03-26T04:40:48.000000Z","updatedAt":"2024-03-26T04:40:48.000000Z"},"relationships":{"customer":{"data":{"type":"user","id":1},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/1"}}},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/orders\/107"}}}
*/
public function replace(ReplaceOrderRequest $request, User $customer, Order $order)
{
}
/**
* Update the specified resource in storage.
* Update a customer's order
*
* Updates a customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer integer required The customer's ID. No-example
* @urlParam order integer required The order ID. No-example
*/
public function update(UpdateOrderRequest $request, User $customer, Order $order)
{
}
/**
* Remove the specified resource from storage.
* Delete a customer's order
*
* Deletes a customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer integer required The customer's ID. No-example
* @urlParam order integer required The order ID. No-example
* @response {}
*/
public function destroy(User $customer, Order $order)
{
if ($this->isAble('delete', $order)) {
$order->delete();
app/Http/Controllers/Api/V1/CustomersController.php
The whole file, with this step's changes marked.
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Filters\V1\CustomerFilter;
use App\Models\User;
use App\Http\Requests\Api\V1\StoreUserRequest;
use App\Http\Requests\Api\V1\UpdateUserRequest;
use App\Http\Resources\V1\UserResource;
class CustomersController extends ApiController
{
/**
* Display a listing of the resource.
* Get customers.
*
* Retrieves all customers who have created an order.
*
* @group Showing Customers
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(
User::has('orders')->filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
//
}
/**
* Display the specified resource.
* Get a customer.
*
* Retrieves a customer who has created an order.
*
* @group Showing Customers
*/
public function show(User $customer)
{
if ($this->include('orders')) {
return new UserResource($customer->load('orders'));
}
return new UserResource($customer);
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateUserRequest $request, User $user)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
//
}
}
app/Http/Controllers/Api/V1/OrderController.php — changes
@queryParam documents the filters and the sort keys.
{
protected $policyClass = OrderPolicy::class;
/**
* Display a listing of the resource.
* Get all orders
*
* @group Managing Orders
* @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=reference,-createdAt
* @queryParam filter[status] Filter by status: pending, paid, shipped, cancelled. No-example
* @queryParam filter[reference] Filter by reference. Wildcards are supported. Example: *ORD-1*
*/
public function index(OrderFilter $filters)
{
}
/**
* Store a newly created resource in storage.
* Create an order
*
* Creates a new order record. Users can only create orders for themselves. Managers can create orders for any user.
*
* @group Managing Orders
*
* @response {"data":{"type":"order","id":107,"attributes":{"reference":"ORD-10432","notes":"Priority delivery","status":"paid","createdAt":"2024-03-26T04:40:48.000000Z","updatedAt":"2024-03-26T04:40:48.000000Z"},"relationships":{"customer":{"data":{"type":"user","id":1},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/1"}}},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/orders\/107"}}}
*/
public function store(StoreOrderRequest $request)
{
}
/**
* Display the specified resource.
* Show a specific order.
*
* Display an individual order.
*
* @group Managing Orders
*
*/
public function show(Order $order)
{
}
/**
* Update the specified resource in storage.
* Update Order
*
* Update the specified order in storage.
*
* @group Managing Orders
*
*/
public function update(UpdateOrderRequest $request, Order $order)
{
}
/**
* Replace the specified resource in storage.
* Replace Order
*
* Replace the specified order in storage.
*
* @group Managing Orders
*
*/
public function replace(ReplaceOrderRequest $request, Order $order)
{
}
/**
* Delete order.
*
* Remove the specified resource from storage.
*
* @group Managing Orders
*
*/
public function destroy(Order $order)
{
app/Http/Controllers/Api/V1/UserController.php — changes
Same annotations on the user endpoints.
{
protected $policyClass = UserPolicy::class;
/**
* Display a listing of the resource.
* Get all users
*
* @group Managing Users
*
* @queryParam sort string Data field(s) to sort by. Separate multiple fields with commas. Denote descending sort with a minus sign. Example: sort=name
* @queryParam filter[name] Filter by status name. Wildcards are supported. No-example
* @queryParam filter[email] Filter by email. Wildcards are supported. No-example
*
*/
public function index(CustomerFilter $filters)
{
}
/**
* Store a newly created resource in storage.
* Create a user
*
* @group Managing Users
*
* @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
*/
public function store(StoreUserRequest $request)
{
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Display the specified resource.
/**
* Display a user
*
* @group Managing Users
*
*
*/
public function show(User $user)
{
return new UserResource($user);
}
/**
* Update the specified resource in storage.
/**
* Update a user
*
* @group Managing Users
*
* @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
*/
public function update(UpdateUserRequest $request, User $user)
{
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Replace the specified resource in storage.
/**
* Replace a user
*
* @group Managing Users
*
* @response 200 {"data":{"type":"user","id":16,"attributes":{"name":"My User","email":"user@user.com","isManager":false},"links":{"self":"http:\/\/localhost:8000\/api\/v1\/customers\/16"}}}
*/
public function replace(ReplaceUserRequest $request, User $user)
{
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Remove the specified resource from storage.
/**
* Delete a user
*
* @group Managing Users
*
* @response 200 {}
*/
public function destroy(User $user)
{
app/Http/Requests/Api/V1/ReplaceOrderRequest.php — changes
Rules for the wrapper keys, so the documented body shape matches what the API accepts.
public function rules(): array
{
$rules = [
'data' => 'required|array',
'data.attributes' => 'required|array',
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
'data.relationships' => 'required|array',
'data.relationships.customer' => 'required|array',
'data.relationships.customer.data' => 'required|array',
'data.relationships.customer.data.id' => 'required|integer',
];
app/Http/Requests/Api/V1/ReplaceUserRequest.php — changes
The same wrapper rules for users.
public function rules(): array
{
$rules = [
'data' => 'required|array',
'data.attributes' => 'required|array',
'data.attributes.name' => 'required|string',
'data.attributes.email' => 'required|email',
'data.attributes.isManager' => 'required|boolean',
app/Http/Requests/Api/V1/StoreOrderRequest.php — changes
Auth::user() replaces $this->user(), which Scribe needs when it reads the rules outside a real request.
The null checks around $user are not defensive padding. Scribe calls rules() while extracting the docs, outside any HTTP request, so there is no authenticated user and Auth::user() returns null. Without the guard, scribe:generate dies with Call to a member function tokenCan() on null and produces no documentation at all. Swapping $this->user() for Auth::user() does not avoid that on its own — both are null during extraction. In a real request the guard never fires, because every one of these routes sits behind auth:sanctum.
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Support\Facades\Auth;
class StoreOrderRequest extends BaseOrderRequest
{
*/
public function rules(): array
{
$customerIdAttr = $this->routeIs('orders.store') ? 'data.relationships.customer.data.id' : 'customer';
$user = $this->user();
$isOrdersController = $this->routeIs('orders.store');
$customerIdAttr = $isOrdersController ? 'data.relationships.customer.data.id' : 'customer';
$user = Auth::user();
$customerRule = 'required|integer|exists:users,id';
$rules = [
'data' => 'required|array',
'data.attributes' => 'required|array',
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:pending,paid,shipped,cancelled',
$customerIdAttr => $customerRule . '|size:' . $user->id
];
if ($isOrdersController) {
$rules['data.relationships'] = 'required|array';
$rules['data.relationships.customer'] = 'required|array';
$rules['data.relationships.customer.data'] = 'required|array';
}
$rules[$customerIdAttr] = $user
? $customerRule . '|size:' . $user->id
: $customerRule;
if ($user && $user->tokenCan(Abilities::CreateOrder)) {
$rules[$customerIdAttr] = $customerRule;
]);
}
}
/**
* Describe the body parameters for the API documentation.
*/
public function bodyParameters()
{
$documentation = [
'data.attributes.reference' => [
'description' => "The order's reference",
'example' => 'No-example'
],
'data.attributes.notes' => [
'description' => "The order's notes",
'example' => 'No-example',
],
'data.attributes.status' => [
'description' => "The order's status",
'example' => 'No-example',
],
];
if ($this->routeIs('orders.store')) {
$documentation['data.relationships.customer.data.id'] = [
'description' => 'The customer the order belongs to.',
'example' => 'No-example'
];
} else {
$documentation['customer'] = [
'description' => 'The customer the order belongs to.',
'example' => 'No-example'
];
}
return $documentation;
}
}
app/Http/Requests/Api/V1/StoreUserRequest.php — changes
And for creating a user.
public function rules(): array
{
return [
'data' => 'required|array',
'data.attributes' => 'required|array',
'data.attributes.name' => 'required|string',
'data.attributes.email' => 'required|email',
'data.attributes.isManager' => 'required|boolean',
app/Http/Requests/Api/V1/UpdateOrderRequest.php — changes
The same switch to Auth::user().
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Support\Facades\Auth;
class UpdateOrderRequest extends BaseOrderRequest
{
'data.relationships.customer.data.id' => 'prohibited',
];
if ($this->user()->tokenCan(Abilities::UpdateOrder)) {
$user = Auth::user();
if ($user && $user->tokenCan(Abilities::UpdateOrder)) {
$rules['data.relationships.customer.data.id'] = 'sometimes|integer';
}
composer.json
Adds knuckleswtf/scribe.
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "An advanced Laravel API tutorial project.",
"keywords": ["laravel", "api"],
"license": "MIT",
"require": {
"php": "^8.3",
"laravel/framework": "^13.0",
"laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"knuckleswtf/scribe": "^5.0",
"laravel/pail": "^1.2.5",
"laravel/pint": "^1.27",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"phpunit/phpunit": "^12.5"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"test": [
"@php artisan config:clear --ansi @no_additional_args",
"@php artisan test"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi",
"@php artisan migrate --graceful --ansi"
],
"pre-package-uninstall": [
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}
config/scribe.php — changes
vendor:publish writes the full file; only a handful of settings need changing. Leave the rest as published — the defaults already match api/*, which is what should be documented.
'title' => config('app.name').' API Documentation',
'title' => 'Orders Hub API Documentation',
'type' => 'laravel',
'type' => 'static',
'try_it_out' => [
'enabled' => true,
'base_url' => null,
'base_url' => 'http://localhost:8000',
],
'auth' => [
'enabled' => false,
'enabled' => true,
'default' => false,
'default' => true,
'in' => AuthIn::BEARER->value,
'name' => 'key',
'name' => 'Authorization',
'use_value' => env('SCRIBE_AUTH_KEY'),
'use_value' => '1|EXAMPLE_TOKEN_REPLACE_ME',
],
// Scribe 5 disables OpenAPI generation by default.
'postman' => [
'enabled' => true,
],
'openapi' => [
'enabled' => true,
],
type decides where the docs are written. static puts them in public/docs/, which is why the browsable page, the OpenAPI file and the Postman collection all land together and need no route. laravel serves them through the app instead.
The auth block is what puts the "Authorization: Bearer" field in the Try It Out panel and marks every endpoint as authenticated except the one carrying @unauthenticated.
Scribe 5 publishes this file using AuthIn::BEARER->value and the Defaults:: strategy constants. If you are following along on an older Scribe, the same keys exist as plain strings and explicit strategy arrays — set the values above and ignore the shape of the surrounding file.
This is why the earlier sections put descriptions in docblocks. A generator can read a docblock. It cannot read an inline comment.
Verify
composer require --dev knuckleswtf/scribe
php artisan vendor:publish --tag=scribe-config
php artisan scribe:generate
Install it as a dev dependency — the composer.json above lists it under require-dev, and nothing in the running API imports it.
scribe:generate should end with All done. It prints a WARN No bodyParameters() method found for each request class that has none, which is only Scribe saying it fell back to reading rules(); the endpoints are still documented.
With 'type' => 'static' everything is written into public/docs/:
php artisan serve
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs/openapi.yaml
curl -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8000/docs/collection.json
Three 200s: the browsable page, the explicitly enabled OpenAPI specification, and the enabled-by-default Postman collection. On 'type' => 'laravel', Scribe writes the collection and specification to storage/app/scribe/ and serves them through the routes configured by laravel.add_routes; the browsable documentation itself is a generated Blade view.
The @group tags are what organise the sidebar, and every endpoint should be filed under one:
| Group | Endpoints |
|---|---|
| Authentication | 2 |
| Managing Orders | 6 |
| Managing Orders by Customer | 5 |
| Managing Users | 6 |
| Showing Customers | 2 |
| Endpoints | 1 |
That last group is Scribe's catch-all for anything without a @group. The one endpoint in it is the /api/v1/user closure in routes/api_v1.php, which has no controller and so no docblock to annotate.
25. Using One Response Format Everywhere
The API currently returns several shapes. /api/v1/user returns a raw user, resource endpoints
return a data wrapper, and errors use an errors wrapper. That forces a client to handle each
endpoint differently. We will make all API responses use message, status, and data.
The response helper
Replace app/Traits/ApiResponses.php with:
<?php
namespace App\Traits;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
trait ApiResponses
{
protected function ok(string $message, mixed $data = null): JsonResponse
{
return $this->success($message, $data);
}
protected function success(string $message, mixed $data = null, int $statusCode = 200): JsonResponse
{
return response()->json([
'message' => $message,
'status' => $statusCode,
'data' => $data,
], $statusCode);
}
protected function resource(
string $message,
JsonResource $resource,
int $statusCode = 200,
): JsonResponse {
$payload = $resource->response()->getData(true);
$data = array_key_exists('meta', $payload)
? [
'items' => $payload['data'],
'links' => $payload['links'],
'meta' => $payload['meta'],
]
: $payload['data'];
return $this->success($message, $data, $statusCode);
}
protected function error(string $message, mixed $data = null, int $statusCode = 400): JsonResponse
{
return response()->json([
'message' => $message,
'status' => $statusCode,
'data' => $data,
], $statusCode);
}
protected function notAuthorized(string $message): JsonResponse
{
return $this->error($message, null, 403);
}
}
resource() handles both resource types. A single resource goes directly into data. A
paginated collection keeps its records, links, and metadata under data.items, data.links, and
data.meta.
Update the controllers
Return resources through the helper instead of returning them directly. These are the changes in
app/Http/Controllers/Api/V1/OrderController.php:
public function index(OrderFilter $filters)
{
return $this->resource(
'Orders retrieved successfully.',
OrderResource::collection(Order::filter($filters)->paginate()),
);
}
public function store(StoreOrderRequest $request)
{
if ($this->isAble('store', Order::class)) {
return $this->resource(
'Order created successfully.',
new OrderResource(Order::create($request->mappedAttributes())),
201,
);
}
return $this->notAuthorized('You are not authorized to create that resource');
}
public function show(Order $order)
{
$order = $this->include('customer') ? $order->load('customer') : $order;
return $this->resource('Order retrieved successfully.', new OrderResource($order));
}
public function update(UpdateOrderRequest $request, Order $order)
{
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return $this->resource('Order updated successfully.', new OrderResource($order));
}
return $this->notAuthorized('You are not authorized to update that resource');
}
public function replace(ReplaceOrderRequest $request, Order $order)
{
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return $this->resource('Order replaced successfully.', new OrderResource($order));
}
return $this->notAuthorized('You are not authorized to update that resource');
}
Use the same calls in the other resource controllers:
// UserController
return $this->resource('Users retrieved successfully.', UserResource::collection($users));
return $this->resource('User retrieved successfully.', new UserResource($user));
return $this->resource('User created successfully.', new UserResource($user), 201);
// CustomersController
return $this->resource('Customers retrieved successfully.', UserResource::collection($customers));
return $this->resource('Customer retrieved successfully.', new UserResource($customer));
// CustomerOrdersController
return $this->resource('Customer orders retrieved successfully.', OrderResource::collection($orders));
return $this->resource('Order created successfully.', new OrderResource($order), 201);
Login and logout already use ApiResponses. Update their messages and pass all three arguments to
error():
if (! Auth::attempt($request->only('email', 'password'))) {
return $this->error('Invalid credentials.', null, 401);
}
return $this->ok('Authenticated successfully.', [
'token' => $user->createToken(
'API token for '.$user->email,
Abilities::getAbilities($user),
now()->addMonth(),
)->plainTextToken,
]);
// logout()
return $this->ok('Logged out successfully.');
The /api/v1/user closure must stop returning the Eloquent model directly. Update it in
routes/api_v1.php:
use App\Http\Resources\V1\UserResource;
Route::get('/user', function (Request $request) {
return response()->json([
'message' => 'Authenticated user retrieved successfully.',
'status' => 200,
'data' => (new UserResource($request->user()))->resolve($request),
]);
});
Format framework errors
Controllers do not produce validation, authentication, routing, or server errors. Add the same
wrapper to the render callbacks in bootstrap/app.php. This is the validation callback:
$exceptions->render(function (ValidationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
$errors = collect($exception->errors())
->flatMap(fn (array $messages, string $field) =>
collect($messages)->map(fn (string $message) => [
'status' => 422,
'message' => $message,
'source' => $field,
])
)->values()->all();
return response()->json([
'message' => 'Validation failed.',
'status' => 422,
'data' => ['errors' => $errors],
], 422);
});
Add these last two callbacks after the specific 404 and 401 renderers:
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
$exceptions->render(function (HttpExceptionInterface $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
$status = $exception->getStatusCode();
return response()->json([
'message' => $status >= 500
? 'Server error.'
: ($exception->getMessage() ?: 'Request failed.'),
'status' => $status,
'data' => null,
], $status, $exception->getHeaders());
});
$exceptions->render(function (\Throwable $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json([
'message' => 'Server error.',
'status' => 500,
'data' => null,
], 500);
});
Keep the existing model-not-found and authentication callbacks, but change their returned arrays
to the same three keys. Do not return exception messages for a 500; they can expose SQL, paths,
or configuration values.
Postman setup
Create postman/Orders-Hub-Local.postman_environment.json:
{
"name": "Orders Hub - Local",
"values": [
{"key": "baseUrl", "value": "http://localhost:8000", "type": "default", "enabled": true},
{"key": "token", "value": "", "type": "secret", "enabled": true}
],
"_postman_variable_scope": "environment"
}
Add this test script to the login request. It saves the token after a successful login:
pm.test('Login succeeded', function () {
pm.response.to.have.status(200);
});
const response = pm.response.json();
if (response?.data?.token) {
pm.environment.set('token', response.data.token);
}
Set the collection authorization type to Bearer Token and use {{token}} as its value. Keep
the login request on No Auth.
Verify
php artisan migrate:fresh --seed
php artisan serve
After logging in, check these requests in Postman:
{
"message": "Orders retrieved successfully.",
"status": 200,
"data": {
"items": [],
"links": {},
"meta": {}
}
}
POST /api/login returns the token in data.token. GET /api/v1/user returns the resource in
data. GET /api/v1/orders returns the rows in data.items without losing pagination links or
metadata.
26. Testing the Response Format
Postman is useful while developing, but it does not stop a later controller change from breaking the response format. Add one unit test for the helper and feature tests for the HTTP endpoints.
tests/Unit/ApiResponsesTest.php
<?php
namespace Tests\Unit;
use App\Traits\ApiResponses;
use Illuminate\Http\JsonResponse;
use Tests\TestCase;
class ApiResponsesTest extends TestCase
{
public function test_success_response_uses_the_standard_format(): void
{
$response = $this->responder()->successResponse(
'Operation completed.',
['id' => 10],
201,
);
$this->assertSame(201, $response->getStatusCode());
$this->assertSame([
'message' => 'Operation completed.',
'status' => 201,
'data' => ['id' => 10],
], $response->getData(true));
}
public function test_error_response_uses_the_standard_format(): void
{
$response = $this->responder()->errorResponse(
'The request is invalid.',
['field' => 'email'],
422,
);
$this->assertSame(422, $response->getStatusCode());
$this->assertSame([
'message' => 'The request is invalid.',
'status' => 422,
'data' => ['field' => 'email'],
], $response->getData(true));
}
private function responder(): object
{
return new class
{
use ApiResponses;
public function successResponse(string $message, mixed $data, int $status): JsonResponse
{
return $this->success($message, $data, $status);
}
public function errorResponse(string $message, mixed $data, int $status): JsonResponse
{
return $this->error($message, $data, $status);
}
};
}
}
tests/Feature/ApiResponseContractTest.php
<?php
namespace Tests\Feature;
use App\Models\Order;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class ApiResponseContractTest extends TestCase
{
use RefreshDatabase;
public function test_login_returns_a_token_in_the_standard_format(): void
{
$user = User::factory()->create();
$this->postJson('/api/login', [
'email' => $user->email,
'password' => 'password',
])->assertOk()
->assertJsonPath('message', 'Authenticated successfully.')
->assertJsonPath('status', 200)
->assertJsonStructure(['message', 'status', 'data' => ['token']]);
}
public function test_validation_errors_use_the_standard_format(): void
{
$this->postJson('/api/login', [])
->assertUnprocessable()
->assertJsonPath('message', 'Validation failed.')
->assertJsonPath('status', 422)
->assertJsonStructure(['message', 'status', 'data' => ['errors']]);
}
public function test_paginated_orders_keep_items_links_and_meta(): void
{
$user = User::factory()->create();
Order::factory()->count(2)->create(['user_id' => $user->id]);
$this->actingAs($user, 'sanctum')
->getJson('/api/v1/orders')
->assertOk()
->assertJsonCount(2, 'data.items')
->assertJsonStructure([
'message',
'status',
'data' => ['items', 'links', 'meta'],
]);
}
public function test_unauthenticated_response_uses_the_standard_format(): void
{
$this->getJson('/api/v1/orders')
->assertUnauthorized()
->assertExactJson([
'message' => 'Unauthenticated.',
'status' => 401,
'data' => null,
]);
}
}
The repository test also covers /api/v1/user and an unknown API route. Those cases catch raw
model responses and unformatted 404 responses.
Run the tests
php artisan test
php artisan test --filter=ApiResponsesTest
php artisan test --filter=ApiResponseContractTest
If these tests pass, regenerate the Scribe files and make one final request from Postman. The generated examples and the application response should now show the same structure.
27. Beyond the Tutorial Checklist
The tutorial establishes the API contract and its main authorization boundaries. When adapting these ideas to a real project, consider the following separately; they are deliberately outside this walkthrough rather than features it claims to implement.
- Apply API rate limits, with stricter throttling for login and other authentication endpoints.
- Cap client-controlled pagination sizes so one request cannot force an unbounded query.
- Restrict CORS to the origins, methods, and headers the deployed clients actually need.
- Wrap related writes in database transactions when they must either all succeed or all roll back.
- Add idempotency handling to write operations that clients or infrastructure may retry.
- Send structured logs, metrics, and alerts to monitoring, while excluding tokens, passwords, credentials, and other sensitive payload data.
- Validate deployment configuration: set
APP_DEBUG=false, terminate traffic over HTTPS, and manage secrets outside source control. - Run and supervise queue workers where background jobs are used; add caching only where invalidation and data freshness are understood.
- Add appropriate security headers at the application or edge layer.
- Automate and test database backups, including restoration procedures.
- Run the automated test suite in CI/CD before deployment, including response-contract and authorization tests.
Conclusion
The resulting service has versioned routes, Sanctum authentication, API Resources, constrained includes, reusable filters, allowlisted sorting, nested resources, distinct PUT and PATCH semantics, policies, token abilities, consistent errors, generated documentation, and one response envelope backed by automated contract tests. The hardening checklist marks the operational work that remains deployment-specific without overstating what the tutorial implements.