Building Production-Ready APIs with Laravel 13: A Professional Guide
A complete Laravel 13 reference for designing, versioning, securing, filtering, authorizing, testing, and documenting a production-ready API.

Building Production-Ready APIs with Laravel 13
This professional guide develops a Laravel API from its first JSON response into a versioned, authenticated, filterable, authorized, 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.
The guide is organized as a progressive technical reference. Each section explains one production 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.
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
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
Create the folder and trait:
mkdir app/Traits
Here is the complete file.
app/Traits/ApiResponses.php
<?php
namespace App\Traits;
trait ApiResponses {
protected function ok($message) {
return $this->success($message, 200);
}
protected function success($message, $statusCode = 200) {
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\Request;
class AuthController extends Controller
{
use ApiResponses;
public function login() {
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 imported Request class is present in the reference implementation but is not used yet.
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 foundation establishes the foundation: an API endpoint, a JSON payload, and an honest HTTP 200 status.
2. Testing APIs with Postman
Use Postman to send API requests, validate input, and inspect JSON errors instead of relying on a browser.
Implementation files
app/Http/Controllers/AuthController.phpapp/Http/Requests/ApiLoginRequest.phproutes/api.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/AuthController.php
<?php
namespace App\Http\Controllers;
use App\Http\Requests\ApiLoginRequest;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
class AuthController extends Controller
{
use ApiResponses;
public function login(ApiLoginRequest $request) {
return $this->ok($request->get('email'));
}
public function register() {
return $this->ok('register');
}
}
app/Http/Requests/ApiLoginRequest.php
<?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
<?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::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
3. Designing Resource-Oriented URLs
Design resource-oriented order endpoints and prepare realistic models, factories, migrations, and seed data.
Orders carry a single-letter status code: P (pending), A (paid), S (shipped), and C (cancelled). Every validation rule, filter, and factory in this guide uses that same set.
Implementation files
app/Http/Controllers/AuthController.phpapp/Http/Requests/ApiLoginRequest.phpapp/Models/Order.phpdatabase/factories/OrderFactory.phpdatabase/migrations/2024_01_27_055742_create_orders_table.phpdatabase/seeders/DatabaseSeeder.phproutes/api.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/AuthController.php
<?php
namespace App\Http\Controllers;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
class AuthController extends Controller
{
use ApiResponses;
public function login() {
return $this->ok('Hello, Login!');
}
}
app/Models/Order.php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Order extends Model
{
use HasFactory;
}
database/factories/OrderFactory.php
<?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(['P', 'A', 'S', 'C']),
];
}
}
database/migrations/2024_01_27_055742_create_orders_table.php
<?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
<?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
<?php
use App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
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();
});
4. Structuring a Versioned API
Introduce a versioned API boundary with dedicated routes, controllers, and request classes for version one.
Implementation files
app/Http/Controllers/Api/V1/OrderController.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Http/Requests/Api/V1/UpdateOrderRequest.phpbootstrap/app.phproutes/api.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/OrderController.php
<?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
<?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
<?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 [
//
];
}
}
routes/api.php
<?php
use Illuminate\Support\Facades\Route;
// Laravel adds the /api prefix when this file is registered in bootstrap/app.php.
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
bootstrap/app.php
<?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
<?php
use App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
routes/api_v1.php
<?php
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
5. Token Authentication with Laravel Sanctum
Authenticate API clients with Laravel Sanctum, issue tokens, and protect versioned routes.
Implementation files
app/Http/Controllers/Api/AuthController.phpapp/Http/Controllers/AuthController.phpapp/Http/Requests/Api/LoginUserRequest.phpapp/Traits/ApiResponses.phproutes/api.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/AuthController.php
<?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\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
use ApiResponses;
public function login(LoginUserRequest $request) {
$request->validated($request->all());
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
<?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
<?php
namespace App\Traits;
trait ApiResponses {
protected function ok($message, $data) {
return $this->success($message, $data, 200);
}
protected function success($message, $data, $statusCode = 200) {
return response()->json([
'data' => $data,
'message' => $message,
'status' => $statusCode
], $statusCode);
}
protected function error($message, $statusCode) {
return response()->json([
'message' => $message,
'status' => $statusCode
], $statusCode);
}
}
routes/api.php
<?php
use App\Http\Controllers\Api\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
routes/api_v1.php
<?php
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::middleware('auth:sanctum')->apiResource('orders', OrderController::class);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
6. Token Revocation and Secure Logout
Log clients out safely by revoking Sanctum tokens and return consistent no-content responses.
Implementation files
app/Http/Controllers/Api/AuthController.phpapp/Traits/ApiResponses.phproutes/api.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/AuthController.php
<?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\Http\Request;
use Illuminate\Support\Facades\Auth;
class AuthController extends Controller
{
use ApiResponses;
public function login(LoginUserRequest $request) {
$request->validated($request->all());
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,
['*'],
now()->addMonth())->plainTextToken
]
);
}
public function logout(Request $request) {
$request->user()->currentAccessToken()->delete();
return $this->ok('');
}
}
app/Traits/ApiResponses.php
<?php
namespace App\Traits;
trait ApiResponses {
protected function ok($message, $data = []) {
return $this->success($message, $data, 200);
}
protected function success($message, $data = [], $statusCode = 200) {
return response()->json([
'data' => $data,
'message' => $message,
'status' => $statusCode
], $statusCode);
}
protected function error($message, $statusCode) {
return response()->json([
'message' => $message,
'status' => $statusCode
], $statusCode);
}
}
routes/api.php
<?php
use App\Http\Controllers\Api\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::prefix('v1')->group(base_path('routes/api_v1.php'));
Route::post('/login', [AuthController::class, 'login']);
Route::post('/register', [AuthController::class, 'register']);
Route::middleware('auth:sanctum')->post('/logout', [AuthController::class, 'logout']);
Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
return $request->user();
});
7. Designing Stable Response Payloads
Transform Eloquent orders into a stable public JSON contract with Laravel API Resources.
Implementation files
app/Http/Controllers/Api/V1/OrderController.phpapp/Http/Resources/V1/OrderResource.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/OrderController.php
<?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;
use App\Http\Resources\V1\OrderResource;
class OrderController extends Controller
{
/**
* Display a listing of the resource.
*/
public function index()
{
return OrderResource::collection(Order::paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
return new OrderResource($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/Resources/V1/OrderResource.php
<?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])]
]
];
}
}
8. Conditional Fields and Relationships
Shape different payloads from the same resources by conditionally exposing fields and relationships.
Implementation files
app/Http/Controllers/Api/V1/UsersController.phpapp/Http/Requests/Api/V1/StoreUserRequest.phpapp/Http/Requests/Api/V1/UpdateUserRequest.phpapp/Http/Resources/V1/OrderResource.phpapp/Http/Resources/V1/UserResource.phpapp/Models/Order.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/UsersController.php
<?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
<?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
<?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
<?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->when(
$request->routeIs('orders.show'),
$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']
]
]
],
'includes' => [
new UserResource($this->user)
],
'links' => [
['self' => route('orders.show', ['order' => $this->id])]
]
];
}
}
app/Http/Resources/V1/UserResource.php
<?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
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Order extends Model
{
use HasFactory;
public function user(): BelongsTo {
return $this->belongsTo(User::class);
}
}
routes/api_v1.php
<?php
use App\Http\Controllers\Api\V1\OrderController;
use App\Http\Controllers\Api\V1\UsersController;
use App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
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();
});
9. Optional Relationship Loading
Let API consumers opt into relationship data through an include query parameter and avoid unnecessary queries.
Implementation files
app/Http/Controllers/Api/V1/ApiController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Controllers/Api/V1/UsersController.phpapp/Http/Resources/V1/OrderResource.phpapp/Http/Resources/V1/UserResource.phpapp/Models/User.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/ApiController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class ApiController extends Controller
{
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
<?php
namespace App\Http\Controllers\Api\V1;
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 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());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
//
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
if ($this->include('customer')) {
return new OrderResource($order->load('user'));
}
return new OrderResource($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/Controllers/Api/V1/UsersController.php
<?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 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());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreUserRequest $request)
{
//
}
/**
* 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 $user)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(User $user)
{
//
}
}
app/Http/Resources/V1/OrderResource.php
<?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->when(
$request->routeIs('orders.show'),
$this->notes
),
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
],
'relationships' => [
'customer' => [
'data' => [
'type' => 'user',
'id' => $this->user_id
],
'links' => [
'self' => route('users.show', ['user' => $this->user_id])
]
]
],
'includes' => new UserResource($this->whenLoaded('user')),
'links' => [
'self' => route('orders.show', ['order' => $this->id])
]
];
}
}
app/Http/Resources/V1/UserResource.php
<?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,
])
],
'includes' => OrderResource::collection($this->whenLoaded('orders')),
'links' => [
'self' => route('users.show', ['user' => $this->id])
]
];
}
}
app/Models/User.php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
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;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function orders() : HasMany {
return $this->hasMany(Order::class);
}
}
10. Reusable Query Filters
Map query-string fields and operators to reusable query filter classes for order searches.
Implementation files
app/Http/Controllers/Api/V1/OrderController.phpapp/Http/Filters/V1/QueryFilter.phpapp/Http/Filters/V1/OrderFilter.phpapp/Http/Resources/V1/OrderResource.phpapp/Models/Order.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/OrderController.php
<?php
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;
use App\Http\Resources\V1\OrderResource;
class OrderController extends ApiController
{
/**
* 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)
{
//
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
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 $order)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Order $order)
{
//
}
}
app/Http/Filters/V1/QueryFilter.php
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter {
protected $builder;
protected $request;
public function __construct(Request $request)
{
$this->request = $request;
}
protected function filter($arr) {
foreach($arr as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
}
}
return $this->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
<?php
namespace App\Http\Filters\V1;
class OrderFilter extends QueryFilter {
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);
}
public function include($value) {
return $this->builder->with($value);
}
public function status($value) {
return $this->builder->whereIn('status', explode(',', $value));
}
public function reference($value) {
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('reference', 'like', $likeStr);
}
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
<?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->when(
$request->routeIs('orders.show'),
$this->notes
),
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
],
'relationships' => [
'customer' => [
'data' => [
'type' => 'user',
'id' => $this->user_id
],
'links' => [
'self' => route('users.show', ['user' => $this->user_id])
]
]
],
'includes' => new UserResource($this->whenLoaded('customer')),
'links' => [
'self' => route('orders.show', ['order' => $this->id])
]
];
}
}
app/Models/Order.php
<?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;
public function customer(): BelongsTo {
return $this->belongsTo(User::class, 'user_id');
}
public function scopeFilter(Builder $builder, QueryFilter $filters) {
return $filters->apply($builder);
}
}
11. Nested Resources and Relationship Filters
Model customers and their orders as nested resources while keeping payload and URL conventions consistent.
Implementation files
app/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/CustomersController.phpapp/Http/Resources/V1/OrderResource.phpapp/Http/Resources/V1/UserResource.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?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;
use Illuminate\Http\Request;
class CustomerOrdersController extends Controller
{
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
<?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
<?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->when(
$request->routeIs('orders.show'),
$this->notes
),
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
],
'relationships' => [
'customer' => [
'data' => [
'type' => 'user',
'id' => $this->user_id
],
'links' => [
'self' => route('customers.show', ['customer' => $this->user_id])
]
]
],
'includes' => new UserResource($this->whenLoaded('customer')),
'links' => [
'self' => route('orders.show', ['order' => $this->id])
]
];
}
}
app/Http/Resources/V1/UserResource.php
<?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('customers.*'), [
'emailVerifiedAt' => $this->email_verified_at,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at,
])
],
'includes' => OrderResource::collection($this->whenLoaded('orders')),
'links' => [
'self' => route('customers.show', ['customer' => $this->id])
]
];
}
}
routes/api_v1.php
<?php
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\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
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')->get('/user', function (Request $request) {
return $request->user();
});
12. Safe Client-Controlled Sorting
Add safe client-controlled sorting without exposing arbitrary database columns to the query string.
Implementation files
app/Http/Controllers/Api/V1/CustomersController.phpapp/Http/Filters/V1/CustomerFilter.phpapp/Http/Filters/V1/QueryFilter.phpapp/Http/Filters/V1/OrderFilter.phpapp/Models/User.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomersController.php
<?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.
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(User::filter($filters)->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/Filters/V1/CustomerFilter.php
<?php
namespace App\Http\Filters\V1;
class CustomerFilter extends QueryFilter {
protected $sortable = [
'name',
'email',
'createdAt' => 'created_at',
'updatedAt' => 'updated_at'
];
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);
}
public function include($value) {
return $this->builder->with($value);
}
public function id($value) {
return $this->builder->whereIn('id', explode(',', $value));
}
public function email($value) {
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('email', 'like', $likeStr);
}
public function name($value) {
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('name', 'like', $likeStr);
}
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
<?php
namespace App\Http\Filters\V1;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
abstract class QueryFilter {
protected $builder;
protected $request;
protected $sortable = [];
public function __construct(Request $request)
{
$this->request = $request;
}
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;
}
protected function filter($arr) {
foreach($arr as $key => $value) {
if (method_exists($this, $key)) {
$this->$key($value);
}
}
return $this->builder;
}
protected function sort($value) {
$sortAttributes = explode(',', $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);
}
}
}
app/Http/Filters/V1/OrderFilter.php
<?php
namespace App\Http\Filters\V1;
class OrderFilter extends QueryFilter {
protected $sortable = [
'reference',
'status',
'createdAt' => 'created_at',
'updatedAt' => 'updated_at'
];
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);
}
public function include($value) {
return $this->builder->with($value);
}
public function status($value) {
return $this->builder->whereIn('status', explode(',', $value));
}
public function reference($value) {
$likeStr = str_replace('*', '%', $value);
return $this->builder->where('reference', 'like', $likeStr);
}
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/Models/User.php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Http\Filters\V1\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
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;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function orders() : HasMany {
return $this->hasMany(Order::class);
}
public function scopeFilter(Builder $builder, QueryFilter $filters) {
return $filters->apply($builder);
}
}
13. Creating Resources with POST
Validate POST payloads, create orders, and return the new resource with the correct HTTP status.
Implementation files
app/Http/Controllers/Api/V1/ApiController.phpapp/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Models/Order.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/ApiController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
class ApiController extends Controller
{
use ApiResponses;
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/CustomerOrdersController.php
<?php
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 Illuminate\Http\Request;
class CustomerOrdersController extends Controller
{
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)
{
$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
<?php
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;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
/**
* 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)
{
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));
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
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 $order)
{
//
}
/**
* Remove the specified resource from storage.
*/
public function destroy(Order $order)
{
//
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
<?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 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:P,A,S,C',
];
if ($this->routeIs('orders.store')) {
$rules['data.relationships.customer.data.id'] = 'required|integer';
}
return $rules;
}
public function messages() {
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use P, A, S, or C.'
];
}
}
app/Models/Order.php
<?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;
protected $fillable = ['reference', 'status', 'notes', 'user_id'];
public function customer(): BelongsTo {
return $this->belongsTo(User::class, 'user_id');
}
public function scopeFilter(Builder $builder, QueryFilter $filters) {
return $filters->apply($builder);
}
}
14. Deleting Resources with DELETE
Delete orders through resource controllers and return a semantically correct empty response.
Implementation files
app/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?php
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 Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class CustomerOrdersController extends ApiController
{
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)
{
$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));
}
/**
* 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
<?php
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;
use App\Http\Resources\V1\OrderResource;
use App\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
/**
* 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)
{
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));
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, Order $order)
{
//
}
/**
* Remove the specified resource from storage.
*/
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);
}
}
}
15. Full Resource Replacement with PUT
Implement full resource replacement, understand PUT semantics, and validate every required field.
Implementation files
app/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Requests/Api/V1/ReplaceOrderRequest.phpapp/Http/Resources/V1/OrderResource.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
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;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Http\Request;
class CustomerOrdersController extends ApiController
{
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)
{
$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));
}
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id) {
// PUT
try {
$order = Order::findOrFail($order_id);
if ($order->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);
return new OrderResource($order);
}
// TODO: order doesn't belong to user
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* 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
<?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\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
/**
* 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)
{
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));
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
}
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);
}
}
/**
* Remove the specified resource from storage.
*/
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);
}
}
}
app/Http/Requests/Api/V1/ReplaceOrderRequest.php
<?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:P,A,S,C',
'data.relationships.customer.data.id' => 'required|integer',
];
return $rules;
}
public function messages() {
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use P, A, S, or C.'
];
}
}
app/Http/Resources/V1/OrderResource.php
<?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->when(
!$request->routeIs(['orders.index', 'customers.orders.index']),
$this->notes
),
'status' => $this->status,
'createdAt' => $this->created_at,
'updatedAt' => $this->updated_at
],
'relationships' => [
'customer' => [
'data' => [
'type' => 'user',
'id' => $this->user_id
],
'links' => [
'self' => route('customers.show', ['customer' => $this->user_id])
]
]
],
'includes' => new UserResource($this->whenLoaded('customer')),
'links' => [
'self' => route('orders.show', ['order' => $this->id])
]
];
}
}
routes/api_v1.php
<?php
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\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
Route::middleware('auth:sanctum')->group(function() {
Route::apiResource('orders', OrderController::class)->except(['update']);
Route::put('orders/{order}', [OrderController::class, 'replace']);
Route::apiResource('customers', CustomersController::class);
Route::apiResource('customers.orders', CustomerOrdersController::class)->except(['update']);
Route::put('customers/{customer}/orders/{order}', [CustomerOrdersController::class, 'replace']);
Route::get('/user', function (Request $request) {
return $request->user();
});
});
16. Partial Resource Updates with PATCH
Implement partial updates while sharing validation rules safely between store, replace, and update requests.
Implementation files
app/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Requests/Api/V1/BaseOrderRequest.phpapp/Http/Requests/Api/V1/ReplaceOrderRequest.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Http/Requests/Api/V1/UpdateOrderRequest.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
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;
use Illuminate\Http\Request;
class CustomerOrdersController extends ApiController
{
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)
{
return new OrderResource(Order::create($request->mappedAttributes()));
}
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id) {
// PUT
try {
$order = Order::findOrFail($order_id);
if ($order->user_id == $customer_id) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
// TODO: order doesn't belong to user
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
public function update(UpdateOrderRequest $request, $customer_id, $order_id) {
// PUT
try {
$order = Order::findOrFail($order_id);
if ($order->user_id == $customer_id) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
// TODO: order doesn't belong to user
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* 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
<?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\Models\User;
use Illuminate\Database\Eloquent\ModelNotFoundException;
class OrderController extends ApiController
{
/**
* 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)
{
try {
User::findOrFail($request->input('data.relationships.customer.data.id'));
} catch (ModelNotFoundException $exception) {
return $this->error('The provided customer id does not exist.', 404);
}
return new OrderResource(Order::create($request->mappedAttributes()));
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
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);
}
}
public function replace(ReplaceOrderRequest $request, $order_id) {
// PUT
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);
}
}
/**
* Remove the specified resource from storage.
*/
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);
}
}
}
app/Http/Requests/Api/V1/BaseOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseOrderRequest extends FormRequest
{
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;
}
public function messages() {
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use P, A, S, or C.'
];
}
}
app/Http/Requests/Api/V1/ReplaceOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class ReplaceOrderRequest extends BaseOrderRequest
{
/**
* 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:P,A,S,C',
'data.relationships.customer.data.id' => 'required|integer',
];
return $rules;
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends BaseOrderRequest
{
/**
* 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:P,A,S,C',
];
if ($this->routeIs('orders.store')) {
$rules['data.relationships.customer.data.id'] = 'required|integer';
}
return $rules;
}
}
app/Http/Requests/Api/V1/UpdateOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends BaseOrderRequest
{
/**
* 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' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:P,A,S,C',
'data.relationships.customer.data.id' => 'sometimes|integer',
];
return $rules;
}
}
routes/api_v1.php
<?php
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\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
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(['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();
});
});
17. Resource Authorization with Policies
Authorize actions against specific order instances with a Laravel policy and controller helpers.
Implementation files
app/Http/Controllers/Api/V1/ApiController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Policies/V1/OrderPolicy.phpapp/Providers/AppServiceProvider.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/ApiController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Http\Request;
class ApiController extends Controller
{
use ApiResponses;
protected $policyClass;
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);
}
public function isAble($ability, $targetModel) {
return $this->authorize($ability, [$targetModel, $this->policyClass]);
}
}
app/Http/Controllers/Api/V1/OrderController.php
<?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\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.
*/
public function index(OrderFilter $filters)
{
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
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);
}
return new OrderResource(Order::create($request->mappedAttributes()));
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
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);
}
}
public function replace(ReplaceOrderRequest $request, $order_id) {
// PUT
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);
}
}
/**
* Remove the specified resource from storage.
*/
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);
}
}
}
app/Policies/V1/OrderPolicy.php
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
class OrderPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
public function update(User $user, Order $order) {
// TODO check for token ability
return $user->id === $order->user_id;
}
}
app/Providers/AppServiceProvider.php
<?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
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
}
}
18. Access Control with Token Abilities
Assign Sanctum token abilities and combine them with policies to express API roles and permissions.
Implementation files
app/Http/Controllers/Api/AuthController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Permissions/V1/Abilities.phpapp/Policies/V1/OrderPolicy.phpdatabase/migrations/2014_10_12_000000_create_users_table.phpdatabase/seeders/DatabaseSeeder.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/AuthController.php
<?php
namespace App\Http\Controllers\Api;
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;
class AuthController extends Controller
{
use ApiResponses;
public function login(LoginUserRequest $request) {
$request->validated($request->all());
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,
Abilities::getAbilities($user),
now()->addMonth())->plainTextToken
]
);
}
public function logout(Request $request) {
$request->user()->currentAccessToken()->delete();
return $this->ok('');
}
}
app/Http/Controllers/Api/V1/OrderController.php
<?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\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.
*/
public function index(OrderFilter $filters)
{
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
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);
}
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
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);
}
}
public function replace(ReplaceOrderRequest $request, $order_id) {
// PUT
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('replace', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($order_id)
{
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('delete', $order);
$order->delete();
return $this->ok('Order successfully deleted');
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
}
app/Permissions/V1/Abilities.php
<?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';
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
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;
class OrderPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
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;
}
public function replace(User $user, Order $order) {
if ($user->tokenCan(Abilities::ReplaceOrder)) {
return true;
}
return false;
}
public function store(User $user) {
if ($user->tokenCan(Abilities::CreateOrder)) {
return true;
}
return false;
}
public function update(User $user, Order $order) {
if ($user->tokenCan(Abilities::UpdateOrder)) {
return true;
} else if ($user->tokenCan(Abilities::UpdateOwnOrder)) {
return $user->id === $order->user_id;
}
return false;
}
}
database/migrations/2014_10_12_000000_create_users_table.php
<?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('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();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('users');
}
};
database/seeders/DatabaseSeeder.php
<?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::create([
'email' => 'manager@manager.com',
'password' => bcrypt('password'),
'name' => 'The Manager',
'is_manager' => true
]);
// \App\Models\User::factory()->create([
// 'name' => 'Test User',
// 'email' => 'test@example.com',
// ]);
}
}
19. Fine-Grained Field Permissions
Restrict individual operations and attributes with fine-grained abilities instead of broad all-or-nothing access.
Implementation files
app/Http/Controllers/Api/V1/OrderController.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Http/Requests/Api/V1/UpdateOrderRequest.phpapp/Permissions/V1/Abilities.phpapp/Policies/V1/OrderPolicy.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/OrderController.php
<?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\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.
*/
public function index(OrderFilter $filters)
{
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
* Store a newly created resource in storage.
*/
public function store(StoreOrderRequest $request)
{
try {
// policy
$this->isAble('store', Order::class);
return new OrderResource(Order::create($request->mappedAttributes()));
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
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);
}
}
public function replace(ReplaceOrderRequest $request, $order_id) {
// PUT
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('replace', $order);
$order->update($request->mappedAttributes());
return new OrderResource($order);
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($order_id)
{
try {
$order = Order::findOrFail($order_id);
// policy
$this->isAble('delete', $order);
$order->delete();
return $this->ok('Order successfully deleted');
} catch (ModelNotFoundException $exception) {
return $this->error('Order cannot be found.', 404);
}
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends BaseOrderRequest
{
/**
* 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:P,A,S,C',
'data.relationships.customer.data.id' => '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;
}
}
return $rules;
}
}
app/Http/Requests/Api/V1/UpdateOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends BaseOrderRequest
{
/**
* 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' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:P,A,S,C',
'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
<?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 CreateOwnOrder = 'order:own:create';
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';
public static function getAbilities(User $user) {
// don't assign '*'
if ($user->is_manager) {
return [
self::CreateOrder,
self::UpdateOrder,
self::ReplaceOrder,
self::DeleteOrder,
self::CreateUser,
self::UpdateUser,
self::ReplaceUser,
self::DeleteUser,
];
} else {
return [
self::CreateOwnOrder,
self::UpdateOwnOrder,
self::DeleteOwnOrder
];
}
}
}
app/Policies/V1/OrderPolicy.php
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;
class OrderPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
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;
}
public function replace(User $user, Order $order) {
if ($user->tokenCan(Abilities::ReplaceOrder)) {
return true;
}
return false;
}
public function store(User $user) {
return $user->tokenCan(Abilities::CreateOrder) ||
$user->tokenCan(Abilities::CreateOwnOrder);
}
public function update(User $user, Order $order) {
if ($user->tokenCan(Abilities::UpdateOrder)) {
return true;
} else if ($user->tokenCan(Abilities::UpdateOwnOrder)) {
return $user->id === $order->user_id;
}
return false;
}
}
20. Customer-Owned Order Operations
Apply filtering, validation, ownership, and authorization consistently to nested customer order endpoints.
Implementation files
app/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Requests/Api/V1/BaseOrderRequest.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Policies/V1/OrderPolicy.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
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;
use Illuminate\Http\Request;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
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(StoreOrderRequest $request, $customer_id)
{
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);
}
}
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);
}
}
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::where('id', $order_id)
->where('user_id', $customer_id)
->firstOrFail();
$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
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseOrderRequest extends FormRequest
{
public function mappedAttributes(array $otherAttributes = []) {
$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) {
if ($this->has($key)) {
$attributesToUpdate[$attribute] = $this->input($key);
}
}
return $attributesToUpdate;
}
public function messages() {
return [
'data.attributes.status' => 'The data.attributes.status value is invalid. Please use P, A, S, or C.'
];
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends BaseOrderRequest
{
/**
* 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
{
$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:P,A,S,C',
$customerIdAttr => 'required|integer|exists:users,id'
];
$user = $this->user();
if ($user->tokenCan(Abilities::CreateOwnOrder)) {
$rules[$customerIdAttr] .= '|size:' . $user->id;
}
return $rules;
}
protected function prepareForValidation() {
if ($this->routeIs('customers.orders.store')) {
$this->merge([
'customer' => $this->route('customer')
]);
}
}
}
app/Policies/V1/OrderPolicy.php
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;
class OrderPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
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;
}
public function replace(User $user, Order $order) {
return $user->tokenCan(Abilities::ReplaceOrder);
}
public function store(User $user) {
return $user->tokenCan(Abilities::CreateOrder) ||
$user->tokenCan(Abilities::CreateOwnOrder);
}
public function update(User $user, Order $order) {
if ($user->tokenCan(Abilities::UpdateOrder)) {
return true;
} else if ($user->tokenCan(Abilities::UpdateOwnOrder)) {
return $user->id === $order->user_id;
}
return false;
}
}
21. Secure User Management
Build complete user-management endpoints with resources, form requests, policies, and route registration.
Implementation files
app/Http/Controllers/Api/V1/CustomersController.phpapp/Http/Controllers/Api/V1/UserController.phpapp/Http/Requests/Api/V1/BaseUserRequest.phpapp/Http/Requests/Api/V1/ReplaceUserRequest.phpapp/Http/Requests/Api/V1/StoreUserRequest.phpapp/Http/Requests/Api/V1/UpdateUserRequest.phpapp/Http/Resources/V1/UserResource.phpapp/Models/User.phpapp/Policies/V1/UserPolicy.phpapp/Providers/AppServiceProvider.phproutes/api_v1.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/CustomersController.php
<?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.
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(
User::select('users.*')
->join('orders', 'users.id', '=', 'orders.user_id')
->filter($filters)
->distinct()
->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/Controllers/Api/V1/UserController.php
<?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('Order cannot be found.', 404);
} catch (AuthorizationException $ex) {
return $this->error('You are not authorized to update that resource', 403);
}
}
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);
}
}
/**
* 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);
}
}
}
app/Http/Requests/Api/V1/BaseUserRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class BaseUserRequest extends FormRequest
{
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
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
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
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest 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
{
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
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class UpdateUserRequest 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
{
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
<?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,
'isManager' => $this->is_manager,
$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('customers.show', ['customer' => $this->id])
]
];
}
}
app/Models/User.php
<?php
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use App\Http\Filters\V1\QueryFilter;
use Illuminate\Database\Eloquent\Builder;
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;
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
/**
* The attributes that are mass assignable.
*
* @var array<int, string>
*/
protected $fillable = [
'name',
'email',
'password',
'is_manager'
];
/**
* The attributes that should be hidden for serialization.
*
* @var array<int, string>
*/
protected $hidden = [
'password',
'remember_token',
];
/**
* The attributes that should be cast.
*
* @var array<string, string>
*/
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'is_manager' => 'boolean'
];
public function orders() : HasMany {
return $this->hasMany(Order::class);
}
public function scopeFilter(Builder $builder, QueryFilter $filters) {
return $filters->apply($builder);
}
}
app/Policies/V1/UserPolicy.php
<?php
namespace App\Policies\V1;
use App\Models\Order;
use App\Models\User;
use App\Permissions\V1\Abilities;
class UserPolicy
{
/**
* Create a new policy instance.
*/
public function __construct()
{
//
}
public function delete(User $user, User $model) {
return $user->tokenCan(Abilities::DeleteUser);
}
public function replace(User $user, User $model) {
return $user->tokenCan(Abilities::ReplaceUser);
}
public function store(User $user) {
return $user->tokenCan(Abilities::CreateUser);
}
public function update(User $user, User $model) {
return $user->tokenCan(Abilities::UpdateUser);
}
}
app/Providers/AppServiceProvider.php
<?php
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;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
//
}
public function boot(): void
{
Gate::policy(Order::class, OrderPolicy::class);
Gate::policy(User::class, UserPolicy::class);
}
}
routes/api_v1.php
<?php
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 App\Http\Controllers\AuthController;
use App\Models\Order;
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!
|
*/
// http://localhost:8000/api/
// universal resource locator
// orders
// users
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('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(['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();
});
});
22. Applying the Principle of Least Privilege
Tighten validation and controller behavior so every role can change only the data it genuinely needs.
Implementation files
app/Http/Controllers/Api/V1/ApiController.phpapp/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Controllers/Api/V1/UserController.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Http/Requests/Api/V1/UpdateOrderRequest.phpapp/Permissions/V1/Abilities.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/V1/ApiController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Http\Request;
class ApiController extends Controller
{
use ApiResponses;
protected $policyClass;
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);
}
public function isAble($ability, $targetModel) {
try {
$this->authorize($ability, [$targetModel, $this->policyClass]);
return true;
} catch (AuthorizationException $ex) {
return false;
}
}
}
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
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;
use Illuminate\Http\Request;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
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(StoreOrderRequest $request, $customer_id)
{
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);
}
public function replace(ReplaceOrderRequest $request, $customer_id, $order_id) {
// 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);
}
}
public function update(UpdateOrderRequest $request, $customer_id, $order_id) {
// 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);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($customer_id, $order_id)
{
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);
}
}
}
app/Http/Controllers/Api/V1/OrderController.php
<?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\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.
*/
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);
}
/**
* Display the specified resource.
*/
public function show($order_id)
{
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);
}
}
/**
* Update the specified resource in storage.
*/
public function update(UpdateOrderRequest $request, $order_id)
{
// PATCH
try {
$order = Order::findOrFail($order_id);
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);
}
}
public function replace(ReplaceOrderRequest $request, $order_id) {
// 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);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($order_id)
{
try {
$order = Order::findOrFail($order_id);
// 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);
}
}
}
app/Http/Controllers/Api/V1/UserController.php
<?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)
{
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);
}
/**
* 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);
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('Order cannot be found.', 404);
}
}
public function replace(ReplaceUserRequest $request, $user_id) {
// PUT
try {
$user = User::findOrFail($user_id);
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);
}
}
/**
* Remove the specified resource from storage.
*/
public function destroy($user_id)
{
try {
$user = User::findOrFail($user_id);
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);
}
}
}
app/Http/Requests/Api/V1/StoreOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
class StoreOrderRequest extends BaseOrderRequest
{
/**
* 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
{
$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:P,A,S,C',
$customerIdAttr => $customerRule . '|size:' . $user->id
];
if ($user->tokenCan(Abilities::CreateOrder)) {
$rules[$customerIdAttr] = $customerRule;
}
return $rules;
}
protected function prepareForValidation() {
if ($this->routeIs('customers.orders.store')) {
$this->merge([
'customer' => $this->route('customer')
]);
}
}
}
app/Http/Requests/Api/V1/UpdateOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
class UpdateOrderRequest extends BaseOrderRequest
{
/**
* 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' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:P,A,S,C',
'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
<?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 CreateOwnOrder = 'order:own:create';
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';
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::CreateOwnOrder,
self::UpdateOwnOrder,
self::DeleteOwnOrder
];
}
}
}
23. Consistent API Error Handling
Normalize exceptions and validation failures into the same predictable JSON error envelope.
Implementation files
bootstrap/app.phpapp/Http/Controllers/Api/V1/ApiController.phpapp/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Controllers/Api/V1/UserController.phpapp/Traits/ApiResponses.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
bootstrap/app.php
<?php
use App\Traits\ApiResponses;
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 Throwable;
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(),
);
$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 (AuthenticationException $exception, Request $request) {
if (! $request->is('api/*')) {
return null;
}
return response()->json(['errors' => [[
'status' => 401,
'message' => 'Unauthenticated.',
'source' => '',
]]], 401);
});
})->create();
app/Http/Controllers/Api/V1/ApiController.php
<?php
namespace App\Http\Controllers\Api\V1;
use App\Http\Controllers\Controller;
use App\Traits\ApiResponses;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Http\Request;
class ApiController extends Controller
{
use ApiResponses;
protected $policyClass;
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);
}
public function isAble($ability, $targetModel) {
try {
$this->authorize($ability, [$targetModel, $this->policyClass]);
return true;
} catch (AuthorizationException $ex) {
return false;
}
}
}
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?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;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
public function index(User $customer, OrderFilter $filters) {
return OrderResource::collection(
Order::where('user_id', $customer->id)->filter($filters)->paginate()
);
}
/**
* Store a newly created resource in storage.
*/
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->notAuthorized('You are not authorized to create that resource');
}
public function replace(ReplaceOrderRequest $request, User $customer, Order $order) {
// PUT
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
public function update(UpdateOrderRequest $request, User $customer, Order $order) {
// PUT
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(User $customer, Order $order)
{
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
<?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\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.
*/
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->notAuthorized('You are not authorized to create that resource');
}
/**
* Display the specified resource.
*/
public function show(Order $order)
{
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 $order)
{
// PATCH
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
public function replace(ReplaceOrderRequest $request, Order $order) {
// PUT
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 $order)
{
// policy
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/UserController.php
<?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)
{
if ($this->isAble('store', User::class)) {
return new UserResource(User::create($request->mappedAttributes()));
}
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 $user)
{
if ($this->isAble('update', $user)) {
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
public function replace(ReplaceUserRequest $request, User $user) {
// PUT
if ($this->isAble('replace', $user)) {
$user->update($request->mappedAttributes());
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 $user)
{
if ($this->isAble('delete', $user)) {
$user->delete();
return $this->ok('User successfully deleted');
}
return $this->notAuthorized('You are not authorized to delete that resource');
}
}
app/Traits/ApiResponses.php
<?php
namespace App\Traits;
trait ApiResponses {
protected function ok($message, $data = []) {
return $this->success($message, $data, 200);
}
protected function success($message, $data = [], $statusCode = 200) {
return response()->json([
'data' => $data,
'message' => $message,
'status' => $statusCode
], $statusCode);
}
protected function error($errors = [], $statusCode = null) {
if (is_string($errors)) {
return response()->json([
'message' => $errors,
'status' => $statusCode
], $statusCode);
}
return response()->json([
'errors' => $errors
], $statusCode);
}
protected function notAuthorized($message) {
return $this->error([[
'status' => 403,
'message' => $message,
'source' => ''
]], 403);
}
}
24. Generating API Documentation with Scribe
Install Scribe, annotate endpoints, and generate interactive API documentation plus OpenAPI and Postman artifacts.
Implementation files
app/Http/Controllers/Api/AuthController.phpapp/Http/Controllers/Api/V1/CustomerOrdersController.phpapp/Http/Controllers/Api/V1/CustomersController.phpapp/Http/Controllers/Api/V1/OrderController.phpapp/Http/Controllers/Api/V1/UserController.phpapp/Http/Requests/Api/V1/ReplaceOrderRequest.phpapp/Http/Requests/Api/V1/ReplaceUserRequest.phpapp/Http/Requests/Api/V1/StoreOrderRequest.phpapp/Http/Requests/Api/V1/StoreUserRequest.phpapp/Http/Requests/Api/V1/UpdateOrderRequest.phpcomposer.jsonconfig/scribe.php
The following sections contain complete file contents, not abbreviated diffs. Unchanged framework-generated files are intentionally not repeated.
Complete code
app/Http/Controllers/Api/AuthController.php
<?php
namespace App\Http\Controllers\Api;
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\JsonResponse;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
class AuthController extends Controller
{
use ApiResponses;
/**
* 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) {
$request->validated($request->all());
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,
Abilities::getAbilities($user),
now()->addMonth())->plainTextToken
]
);
}
/**
* Logout
*
* Signs out the user and destroy's the API token.
*
* @group Authentication
* @response 200 {}
*/
public function logout(Request $request) {
$request->user()->currentAccessToken()->delete();
return $this->ok('');
}
}
app/Http/Controllers/Api/V1/CustomerOrdersController.php
<?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;
class CustomerOrdersController extends ApiController
{
protected $policyClass = OrderPolicy::class;
/**
* Get all orders
*
* Retrieves all orders created by a specific user.
*
* @group Managing Orders by Customer
*
* @urlParam customer_id 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) {
return OrderResource::collection(
Order::where('user_id', $customer->id)->filter($filters)->paginate()
);
}
/**
* Create a order
*
* Creates a order for the specific user.
*
* @group Managing Orders by Customer
*
* @urlParam customer_id integer required The customer's ID. No-example
*
*/
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->notAuthorized('You are not authorized to create that resource');
}
/**
* Replace an customer's order
*
* Replaces an customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer_id integer required The customer's ID. No-example
* @urlParam order_id integer required The order ID. No-example
* @response {"data":{"type":"order","id":107,"attributes":{"reference":"ORD-10432","notes":"Priority delivery","status":"A","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) {
// PUT
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 an customer's order
*
* Updates an customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer_id integer required The customer's ID. No-example
* @urlParam order_id integer required The order ID. No-example
*/
public function update(UpdateOrderRequest $request, User $customer, Order $order) {
// PUT
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Delete an customer's order
*
* Deletes an customer's order.
*
* @group Managing Orders by Customer
* @urlParam customer_id integer required The customer's ID. No-example
* @urlParam id integer required The order ID. No-example
* @response {}
*/ public function destroy(User $customer, Order $order)
{
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/CustomersController.php
<?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
{
/**
* Get customers.
*
* Retrieves all users that created a order.
*
* @group Showing Customers
*/
public function index(CustomerFilter $filters)
{
return UserResource::collection(
User::select('users.*')
->join('orders', 'users.id', '=', 'orders.user_id')
->filter($filters)
->distinct()
->paginate()
);
}
/**
* Get an customer.
*
* Retrieves all users that created a order.
*
* @group Showing Customers
*/
public function show(User $customer)
{
if ($this->include('orders')) {
return new UserResource($customer->load('orders'));
}
return new UserResource($customer);
}
}
app/Http/Controllers/Api/V1/OrderController.php
<?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\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;
/**
* 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 code: P, A, S, C. No-example
* @queryParam filter[reference] Filter by reference. Wildcards are supported. Example: *ORD-1*
*/
public function index(OrderFilter $filters)
{
return OrderResource::collection(Order::filter($filters)->paginate());
}
/**
* Create a 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":"A","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)
{
if ($this->isAble('store', Order::class)) {
return new OrderResource(Order::create($request->mappedAttributes()));
}
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Show a specific order.
*
* Display an individual order.
*
* @group Managing Orders
*
*/
public function show(Order $order)
{
if ($this->include('customer')) {
return new OrderResource($order->load('customer'));
}
return new OrderResource($order);
}
/**
* Update Order
*
* Update the specified order in storage.
*
* @group Managing Orders
*
*/
public function update(UpdateOrderRequest $request, Order $order)
{
// PATCH
if ($this->isAble('update', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Replace Order
*
* Replace the specified order in storage.
*
* @group Managing Orders
*
*/
public function replace(ReplaceOrderRequest $request, Order $order) {
// PUT
if ($this->isAble('replace', $order)) {
$order->update($request->mappedAttributes());
return new OrderResource($order);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Delete order.
*
* Remove the specified resource from storage.
*
* @group Managing Orders
*
*/
public function destroy(Order $order)
{
// policy
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/UserController.php
<?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;
/**
* 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)
{
return UserResource::collection(
User::filter($filters)->paginate()
);
}
/**
* 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)
{
if ($this->isAble('store', User::class)) {
return new UserResource(User::create($request->mappedAttributes()));
}
return $this->notAuthorized('You are not authorized to create that resource');
}
/**
* Display a user
*
* @group Managing Users
*
*
*/
public function show(User $user)
{
if ($this->include('orders')) {
return new UserResource($user->load('orders'));
}
return new UserResource($user);
}
/**
* 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)
{
if ($this->isAble('update', $user)) {
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* 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) {
// PUT
if ($this->isAble('replace', $user)) {
$user->update($request->mappedAttributes());
return new UserResource($user);
}
return $this->notAuthorized('You are not authorized to update that resource');
}
/**
* Delete a user
*
* @group Managing Users
*
* @response 200 {}
*/
public function destroy(User $user)
{
if ($this->isAble('delete', $user)) {
$user->delete();
return $this->ok('User successfully deleted');
}
return $this->notAuthorized('You are not authorized to delete that resource');
}
}
app/Http/Requests/Api/V1/ReplaceOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class ReplaceOrderRequest extends BaseOrderRequest
{
/**
* 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' => 'required|array',
'data.attributes' => 'required|array',
'data.attributes.reference' => 'required|string',
'data.attributes.notes' => 'required|string',
'data.attributes.status' => 'required|string|in:P,A,S,C',
'data.relationships' => 'required|array',
'data.relationships.customer' => 'required|array',
'data.relationships.customer.data' => 'required|array',
'data.relationships.customer.data.id' => 'required|integer',
];
return $rules;
}
}
app/Http/Requests/Api/V1/ReplaceUserRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
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' => 'required|array',
'data.attributes' => 'required|array',
'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/StoreOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class StoreOrderRequest extends BaseOrderRequest
{
/**
* 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
{
$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:P,A,S,C',
];
if ($isOrdersController) {
$rules['data.relationships'] = 'required|array';
$rules['data.relationships.customer'] = 'required|array';
$rules['data.relationships.customer.data'] = 'required|array';
}
$rules[$customerIdAttr] = $customerRule . '|size:' . $user->id;
if ($user->tokenCan(Abilities::CreateOrder)) {
$rules[$customerIdAttr] = $customerRule;
}
return $rules;
}
protected function prepareForValidation() {
if ($this->routeIs('customers.orders.store')) {
$this->merge([
'customer' => $this->route('customer')
]);
}
}
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
<?php
namespace App\Http\Requests\Api\V1;
use Illuminate\Foundation\Http\FormRequest;
class StoreUserRequest 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
{
return [
'data' => 'required|array',
'data.attributes' => 'required|array',
'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/UpdateOrderRequest.php
<?php
namespace App\Http\Requests\Api\V1;
use App\Permissions\V1\Abilities;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\Auth;
class UpdateOrderRequest extends BaseOrderRequest
{
/**
* 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' => 'sometimes|string',
'data.attributes.notes' => 'sometimes|string',
'data.attributes.status' => 'sometimes|string|in:P,A,S,C',
'data.relationships.customer.data.id' => 'prohibited',
];
if (Auth::user()->tokenCan(Abilities::UpdateOrder)) {
$rules['data.relationships.customer.data.id'] = 'sometimes|integer';
}
return $rules;
}
}
composer.json
{
"$schema": "https://getcomposer.org/schema.json",
"name": "laravel/laravel",
"type": "project",
"description": "A production-ready Laravel API.",
"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
<?php
use Knuckles\Scribe\Extracting\Strategies;
return [
// The HTML <title> for the generated documentation. If this is empty, Scribe will infer it from config('app.name').
'title' => 'Orders Hub API Documentation',
// A short description of your API. Will be included in the docs webpage, Postman collection and OpenAPI spec.
'description' => '',
// The base URL displayed in the docs. If this is empty, Scribe will use the value of config('app.url') at generation time.
// If you're using `laravel` type, you can set this to a dynamic string, like '{{ config("app.tenant_url") }}' to get a dynamic base URL.
'base_url' => null,
'routes' => [
[
// Routes that match these conditions will be included in the docs
'match' => [
// Match only routes whose paths match this pattern (use * as a wildcard to match any characters). Example: 'users/*'.
'prefixes' => ['api/*'],
// Match only routes whose domains match this pattern (use * as a wildcard to match any characters). Example: 'api.*'.
'domains' => ['*'],
],
// Include these routes even if they did not match the rules above.
'include' => [
// 'users.index', 'POST /new', '/auth/*'
],
// Exclude these routes even if they matched the rules above.
'exclude' => [
// 'GET /health', 'admin.*'
],
],
],
// The type of documentation output to generate.
// - "static" will generate a static HTMl page in the /public/docs folder,
// - "laravel" will generate the documentation as a Blade view, so you can add routing and authentication.
// - "external_static" and "external_laravel" do the same as above, but generate a basic template,
// passing the OpenAPI spec as a URL, allowing you to easily use the docs with an external generator
'type' => 'static',
// See https://scribe.knuckles.wtf/laravel/reference/config#theme for supported options
'theme' => 'default',
'static' => [
// HTML documentation, assets and Postman collection will be generated to this folder.
// Source Markdown will still be in resources/docs.
'output_path' => 'public/docs',
],
'laravel' => [
// Whether to automatically create a docs endpoint for you to view your generated docs.
// If this is false, you can still set up routing manually.
'add_routes' => true,
// URL path to use for the docs endpoint (if `add_routes` is true).
// By default, `/docs` opens the HTML page, `/docs.postman` opens the Postman collection, and `/docs.openapi` the OpenAPI spec.
'docs_url' => '/docs',
// Directory within `public` in which to store CSS and JS assets.
// By default, assets are stored in `public/vendor/scribe`.
// If set, assets will be stored in `public/{{assets_directory}}`
'assets_directory' => null,
// Middleware to attach to the docs endpoint (if `add_routes` is true).
'middleware' => [],
],
'external' => [
'html_attributes' => []
],
'try_it_out' => [
// Add a Try It Out button to your endpoints so consumers can test endpoints right from their browser.
// Don't forget to enable CORS headers for your endpoints.
'enabled' => true,
// The base URL for the API tester to use (for example, you can set this to your staging URL).
// Leave as null to use the current app URL when generating (config("app.url")).
'base_url' => 'http://localhost:8000',
// [Laravel Sanctum] Fetch a CSRF token before each request, and add it as an X-XSRF-TOKEN header.
'use_csrf' => false,
// The URL to fetch the CSRF token from (if `use_csrf` is true).
'csrf_url' => '/sanctum/csrf-cookie',
],
// How is your API authenticated? This information will be used in the displayed docs, generated examples and response calls.
'auth' => [
// Set this to true if ANY endpoints in your API use authentication.
'enabled' => true,
// Set this to true if your API should be authenticated by default. If so, you must also set `enabled` (above) to true.
// You can then use @unauthenticated or @authenticated on individual endpoints to change their status from the default.
'default' => true,
// Where is the auth value meant to be sent in a request?
// Options: query, body, basic, bearer, header (for custom header)
'in' => 'bearer',
// The name of the auth parameter (eg token, key, apiKey) or header (eg Authorization, Api-Key).
'name' => 'Authorization',
// The value of the parameter to be used by Scribe to authenticate response calls.
// This will NOT be included in the generated documentation. If empty, Scribe will use a random value.
'use_value' => '1|EXAMPLE_TOKEN_REPLACE_ME',
// Placeholder your users will see for the auth parameter in the example requests.
// Set this to null if you want Scribe to use a random value as placeholder instead.
'placeholder' => '{YOUR_AUTH_KEY}',
// Any extra authentication-related info for your users. Markdown and HTML are supported.
'extra_info' => 'You can retrieve your token by visiting your dashboard and clicking <b>Generate API token</b>.',
],
// Text to place in the "Introduction" section, right after the `description`. Markdown and HTML are supported.
'intro_text' => <<<INTRO
This documentation aims to provide all the information you need to work with our API.
<aside>As you scroll, you'll see code examples for working with the API in different programming languages in the dark area to the right (or as part of the content on mobile).
You can switch the language used with the tabs at the top right (or from the nav menu at the top left on mobile).</aside>
INTRO
,
// Example requests for each endpoint will be shown in each of these languages.
// Supported options are: bash, javascript, php, python
// To add a language of your own, see https://scribe.knuckles.wtf/laravel/advanced/example-requests
'example_languages' => [
'bash',
'javascript',
],
// Generate a Postman collection (v2.1.0) in addition to HTML docs.
// For 'static' docs, the collection will be generated to public/docs/collection.json.
// For 'laravel' docs, it will be generated to storage/app/scribe/collection.json.
// Setting `laravel.add_routes` to true (above) will also add a route for the collection.
'postman' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
// Generate an OpenAPI spec (v3.0.1) in addition to docs webpage.
// For 'static' docs, the collection will be generated to public/docs/openapi.yaml.
// For 'laravel' docs, it will be generated to storage/app/scribe/openapi.yaml.
// Setting `laravel.add_routes` to true (above) will also add a route for the spec.
'openapi' => [
'enabled' => true,
'overrides' => [
// 'info.version' => '2.0.0',
],
],
'groups' => [
// Endpoints which don't have a @group will be placed in this default group.
'default' => 'Endpoints',
// By default, Scribe will sort groups alphabetically, and endpoints in the order their routes are defined.
// You can override this by listing the groups, subgroups and endpoints here in the order you want them.
// See https://scribe.knuckles.wtf/blog/laravel-v4#easier-sorting and https://scribe.knuckles.wtf/laravel/reference/config#order for details
'order' => [],
],
// Custom logo path. This will be used as the value of the src attribute for the <img> tag,
// so make sure it points to an accessible URL or path. Set to false to not use a logo.
// For example, if your logo is in public/img:
// - 'logo' => '../img/logo.png' // for `static` type (output folder is public/docs)
// - 'logo' => 'img/logo.png' // for `laravel` type
'logo' => false,
// Customize the "Last updated" value displayed in the docs by specifying tokens and formats.
// Examples:
// - {date:F j Y} => March 28, 2022
// - {git:short} => Short hash of the last Git commit
// Available tokens are `{date:<format>}` and `{git:<format>}`.
// The format you pass to `date` will be passed to PHP's `date()` function.
// The format you pass to `git` can be either "short" or "long".
'last_updated' => 'Last updated: {date:F j, Y}',
'examples' => [
// Set this to any number (eg. 1234) to generate the same example values for parameters on each run,
'faker_seed' => null,
// With API resources and transformers, Scribe tries to generate example models to use in your API responses.
// By default, Scribe will try the model's factory, and if that fails, try fetching the first from the database.
// You can reorder or remove strategies here.
'models_source' => ['factoryCreate', 'factoryMake', 'databaseFirst'],
],
// The strategies Scribe will use to extract information about your routes at each stage.
// If you create or install a custom strategy, add it here.
'strategies' => [
'metadata' => [
Strategies\Metadata\GetFromDocBlocks::class,
Strategies\Metadata\GetFromMetadataAttributes::class,
],
'urlParameters' => [
Strategies\UrlParameters\GetFromLaravelAPI::class,
Strategies\UrlParameters\GetFromUrlParamAttribute::class,
Strategies\UrlParameters\GetFromUrlParamTag::class,
],
'queryParameters' => [
Strategies\QueryParameters\GetFromFormRequest::class,
Strategies\QueryParameters\GetFromInlineValidator::class,
Strategies\QueryParameters\GetFromQueryParamAttribute::class,
Strategies\QueryParameters\GetFromQueryParamTag::class,
],
'headers' => [
Strategies\Headers\GetFromHeaderAttribute::class,
Strategies\Headers\GetFromHeaderTag::class,
[
'override',
[
'Content-Type' => 'application/json',
'Accept' => 'application/json',
]
]
],
'bodyParameters' => [
Strategies\BodyParameters\GetFromFormRequest::class,
Strategies\BodyParameters\GetFromInlineValidator::class,
Strategies\BodyParameters\GetFromBodyParamAttribute::class,
Strategies\BodyParameters\GetFromBodyParamTag::class,
],
'responses' => [
Strategies\Responses\UseResponseAttributes::class,
Strategies\Responses\UseTransformerTags::class,
Strategies\Responses\UseApiResourceTags::class,
Strategies\Responses\UseResponseTag::class,
Strategies\Responses\UseResponseFileTag::class,
[
Strategies\Responses\ResponseCalls::class,
['only' => ['GET *']]
]
],
'responseFields' => [
Strategies\ResponseFields\GetFromResponseFieldAttribute::class,
Strategies\ResponseFields\GetFromResponseFieldTag::class,
],
],
// For response calls, API resource responses and transformer responses,
// Scribe will try to start database transactions, so no changes are persisted to your database.
// Tell Scribe which connections should be transacted here. If you only use one db connection, you can leave this as is.
'database_connections_to_transact' => [config('database.default')],
'fractal' => [
// If you are using a custom serializer with league/fractal, you can specify it here.
'serializer' => null,
],
'routeMatcher' => \Knuckles\Scribe\Matching\RouteMatcher::class,
];