Skip to main content
Back to ScopeForged

ScopeForged Documentation

Technical documentation, guides, and feature references for the ScopeForged client portal.

Backend Features/API Standards

API Standards Guide

Last Updated: 2026-01-23 Status: Active Audience: Developers

This guide documents API design patterns and standards for the Client Portal application.


Table of Contents

  1. Overview
  2. API Structure
  3. Authentication
  4. Request Handling
  5. Response Formats
  6. Error Handling
  7. Versioning
  8. Rate Limiting
  9. Testing
  10. Best Practices
  11. API Explorer
  12. SDK Generation
  13. API Analytics
  14. API Debug Logging
  15. API Versioning

Overview

The Client Portal API follows RESTful conventions with JSON responses.

Design Principles

  • Consistent: Same patterns across all endpoints
  • Predictable: Standard HTTP methods and status codes
  • Secure: Authentication required, proper authorization
  • Documented: Clear endpoint documentation

API Structure

Route Organization

// routes/api.php
<?php

use Illuminate\Support\Facades\Route;

Route::prefix('v1')->group(function () {
    // Public routes
    Route::post('login', [AuthController::class, 'login']);
    Route::post('register', [AuthController::class, 'register']);

    // Authenticated routes
    Route::middleware('auth:sanctum')->group(function () {
        // User endpoints
        Route::get('user', [UserController::class, 'show']);
        Route::put('user', [UserController::class, 'update']);
        Route::post('logout', [AuthController::class, 'logout']);

        // Resource routes
        Route::apiResource('clients', ClientController::class);
        Route::apiResource('projects', ProjectController::class);
        Route::apiResource('invoices', InvoiceController::class);

        // Nested resources
        Route::apiResource('clients.projects', ClientProjectController::class)
            ->shallow();

        // Custom actions
        Route::post('invoices/{invoice}/send', [InvoiceController::class, 'send']);
        Route::post('invoices/{invoice}/pay', [InvoiceController::class, 'pay']);
    });
});

URL Conventions

ConventionExample
Plural nouns/api/v1/clients
Lowercase/api/v1/project-files
Hyphens for multi-word/api/v1/invoice-items
Nested resources/api/v1/clients/{client}/projects
Actions as verbs/api/v1/invoices/{invoice}/send

HTTP Methods

MethodPurposeExample
GETRetrieve resource(s)GET /clients
POSTCreate resourcePOST /clients
PUT/PATCHUpdate resourcePUT /clients/1
DELETEDelete resourceDELETE /clients/1

Authentication

Sanctum Token Authentication

// Install Sanctum
php artisan install:api

// AuthController
<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Requests\Api\LoginRequest;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;

class AuthController extends Controller
{
    public function login(LoginRequest $request): JsonResponse
    {
        $user = User::where('email', $request->email)->first();

        if (!$user || !Hash::check($request->password, $user->password)) {
            throw ValidationException::withMessages([
                'email' => ['The provided credentials are incorrect.'],
            ]);
        }

        $token = $user->createToken(
            $request->device_name ?? 'api',
            ['*'], // abilities
            now()->addDays(30) // expiration
        );

        return response()->json([
            'success' => true,
            'data' => [
                'user' => new UserResource($user),
                'token' => $token->plainTextToken,
                'expires_at' => $token->accessToken->expires_at,
            ],
        ]);
    }

    public function logout(): JsonResponse
    {
        auth()->user()->currentAccessToken()->delete();

        return response()->json([
            'success' => true,
            'message' => 'Successfully logged out.',
        ]);
    }

    public function logoutAll(): JsonResponse
    {
        auth()->user()->tokens()->delete();

        return response()->json([
            'success' => true,
            'message' => 'Logged out from all devices.',
        ]);
    }
}

Using Authentication

# Login to get token
curl -X POST https://api.example.com/v1/login \
  -H "Content-Type: application/json" \
  -d '{"email": "user@example.com", "password": "secret"}'

# Use token in requests
curl https://api.example.com/v1/clients \
  -H "Authorization: Bearer {token}" \
  -H "Accept: application/json"

Token Abilities (Scopes)

// Create token with limited abilities
$token = $user->createToken('read-only', ['clients:read', 'invoices:read']);

// Check abilities in controller
public function destroy(Client $client): JsonResponse
{
    if (!auth()->user()->tokenCan('clients:delete')) {
        abort(403, 'Token does not have required permissions.');
    }

    $client->delete();

    return response()->json(['success' => true]);
}

Request Handling

API Form Requests

<?php

namespace App\Http\Requests\Api;

use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Http\Exceptions\HttpResponseException;

abstract class ApiFormRequest extends FormRequest
{
    /**
     * Handle a failed validation attempt.
     */
    protected function failedValidation(Validator $validator): void
    {
        throw new HttpResponseException(
            response()->json([
                'success' => false,
                'message' => 'Validation failed.',
                'errors' => $validator->errors(),
            ], 422)
        );
    }

    /**
     * Handle a failed authorization attempt.
     */
    protected function failedAuthorization(): void
    {
        throw new HttpResponseException(
            response()->json([
                'success' => false,
                'message' => 'You are not authorized to perform this action.',
            ], 403)
        );
    }
}
<?php

namespace App\Http\Requests\Api;

class StoreClientRequest extends ApiFormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Client::class);
    }

    public function rules(): array
    {
        return [
            'company_name' => ['required', 'string', 'max:255'],
            'contact_name' => ['required', 'string', 'max:255'],
            'email' => ['required', 'email', 'unique:clients,email'],
            'phone' => ['nullable', 'string', 'max:20'],
            'address' => ['nullable', 'string', 'max:500'],
        ];
    }
}

Query Parameters

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Http\Resources\ClientResource;
use App\Models\Client;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;

class ClientController extends Controller
{
    public function index(Request $request): AnonymousResourceCollection
    {
        $query = Client::query();

        // Search
        if ($request->has('search')) {
            $search = $request->input('search');
            $query->where(function ($q) use ($search) {
                $q->where('company_name', 'like', "%{$search}%")
                  ->orWhere('contact_name', 'like', "%{$search}%")
                  ->orWhere('email', 'like', "%{$search}%");
            });
        }

        // Filter by status
        if ($request->has('status')) {
            $query->where('status', $request->input('status'));
        }

        // Sort
        $sortField = $request->input('sort', 'created_at');
        $sortDirection = $request->input('direction', 'desc');
        $allowedSorts = ['company_name', 'created_at', 'updated_at'];

        if (in_array($sortField, $allowedSorts)) {
            $query->orderBy($sortField, $sortDirection === 'asc' ? 'asc' : 'desc');
        }

        // Include relationships
        if ($request->has('include')) {
            $includes = explode(',', $request->input('include'));
            $allowed = ['projects', 'invoices', 'user'];
            $query->with(array_intersect($includes, $allowed));
        }

        // Paginate
        $perPage = min($request->input('per_page', 15), 100);

        return ClientResource::collection($query->paginate($perPage));
    }
}

Response Formats

API Resources

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class ClientResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'company_name' => $this->company_name,
            'contact_name' => $this->contact_name,
            'email' => $this->email,
            'phone' => $this->phone,
            'status' => $this->status,
            'created_at' => $this->created_at->toIso8601String(),
            'updated_at' => $this->updated_at->toIso8601String(),

            // Conditional relationships
            'projects' => ProjectResource::collection($this->whenLoaded('projects')),
            'invoices' => InvoiceResource::collection($this->whenLoaded('invoices')),

            // Conditional attributes
            'projects_count' => $this->when(
                isset($this->projects_count),
                $this->projects_count
            ),

            // Links
            'links' => [
                'self' => route('api.v1.clients.show', $this->id),
                'projects' => route('api.v1.clients.projects.index', $this->id),
            ],
        ];
    }
}

Resource Collections

<?php

namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\ResourceCollection;

class ClientCollection extends ResourceCollection
{
    public function toArray(Request $request): array
    {
        return [
            'data' => $this->collection,
            'meta' => [
                'total' => $this->total(),
                'per_page' => $this->perPage(),
                'current_page' => $this->currentPage(),
                'last_page' => $this->lastPage(),
            ],
            'links' => [
                'first' => $this->url(1),
                'last' => $this->url($this->lastPage()),
                'prev' => $this->previousPageUrl(),
                'next' => $this->nextPageUrl(),
            ],
        ];
    }
}

Standard Response Structure

// Success response
{
    "success": true,
    "data": { ... },
    "message": "Optional success message"
}

// Collection response
{
    "success": true,
    "data": [ ... ],
    "meta": {
        "total": 100,
        "per_page": 15,
        "current_page": 1,
        "last_page": 7
    },
    "links": {
        "first": "...",
        "last": "...",
        "prev": null,
        "next": "..."
    }
}

// Error response
{
    "success": false,
    "message": "Error description",
    "errors": {
        "field": ["Error message"]
    }
}

Base API Controller

<?php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;

class ApiController extends Controller
{
    /**
     * Return a success response.
     */
    protected function success(
        mixed $data = null,
        string $message = null,
        int $code = 200
    ): JsonResponse {
        $response = ['success' => true];

        if ($message !== null) {
            $response['message'] = $message;
        }

        if ($data !== null) {
            $response['data'] = $data;
        }

        return response()->json($response, $code);
    }

    /**
     * Return a created response.
     */
    protected function created(mixed $data, string $message = 'Resource created.'): JsonResponse
    {
        return $this->success($data, $message, 201);
    }

    /**
     * Return a no content response.
     */
    protected function noContent(): JsonResponse
    {
        return response()->json(null, 204);
    }

    /**
     * Return an error response.
     */
    protected function error(
        string $message,
        int $code = 400,
        array $errors = []
    ): JsonResponse {
        $response = [
            'success' => false,
            'message' => $message,
        ];

        if (!empty($errors)) {
            $response['errors'] = $errors;
        }

        return response()->json($response, $code);
    }
}

Error Handling

HTTP Status Codes

CodeMeaningUse Case
200OKSuccessful GET/PUT
201CreatedSuccessful POST
204No ContentSuccessful DELETE
400Bad RequestInvalid request format
401UnauthorizedMissing/invalid auth
403ForbiddenAuthenticated but not authorized
404Not FoundResource doesn't exist
422Unprocessable EntityValidation failed
429Too Many RequestsRate limit exceeded
500Internal Server ErrorServer error

Global Exception Handler

// bootstrap/app.php
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Http\Request;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

return Application::configure(basePath: dirname(__DIR__))
    ->withExceptions(function (Exceptions $exceptions) {
        // Handle API exceptions
        $exceptions->render(function (Throwable $e, Request $request) {
            if ($request->is('api/*') || $request->expectsJson()) {
                return $this->handleApiException($e);
            }
        });
    })->create();

private function handleApiException(Throwable $e): JsonResponse
{
    if ($e instanceof ModelNotFoundException) {
        return response()->json([
            'success' => false,
            'message' => 'Resource not found.',
        ], 404);
    }

    if ($e instanceof AuthenticationException) {
        return response()->json([
            'success' => false,
            'message' => 'Unauthenticated.',
        ], 401);
    }

    if ($e instanceof AuthorizationException) {
        return response()->json([
            'success' => false,
            'message' => $e->getMessage() ?: 'Forbidden.',
        ], 403);
    }

    if ($e instanceof ValidationException) {
        return response()->json([
            'success' => false,
            'message' => 'Validation failed.',
            'errors' => $e->errors(),
        ], 422);
    }

    if ($e instanceof ThrottleRequestsException) {
        return response()->json([
            'success' => false,
            'message' => 'Too many requests. Please try again later.',
        ], 429);
    }

    // Log unexpected errors
    Log::error('API Error', [
        'exception' => get_class($e),
        'message' => $e->getMessage(),
        'trace' => $e->getTraceAsString(),
    ]);

    return response()->json([
        'success' => false,
        'message' => app()->environment('production')
            ? 'An unexpected error occurred.'
            : $e->getMessage(),
    ], 500);
}

Versioning

URL Versioning

// routes/api.php
Route::prefix('v1')->name('api.v1.')->group(function () {
    Route::apiResource('clients', Api\V1\ClientController::class);
});

Route::prefix('v2')->name('api.v2.')->group(function () {
    Route::apiResource('clients', Api\V2\ClientController::class);
});

Controller Organization

app/Http/Controllers/Api/
├── V1/
│   ├── ClientController.php
│   └── InvoiceController.php
└── V2/
    ├── ClientController.php
    └── InvoiceController.php

Resource Versioning

// app/Http/Resources/V1/ClientResource.php
namespace App\Http\Resources\V1;

class ClientResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'name' => $this->company_name, // V1 uses 'name'
            // ...
        ];
    }
}

// app/Http/Resources/V2/ClientResource.php
namespace App\Http\Resources\V2;

class ClientResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'company_name' => $this->company_name, // V2 uses 'company_name'
            'company' => [
                'name' => $this->company_name,
                'address' => $this->address,
            ],
            // ...
        ];
    }
}

Rate Limiting

Configure Rate Limits

// bootstrap/app.php or AppServiceProvider
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;

RateLimiter::for('api', function (Request $request) {
    return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});

RateLimiter::for('auth', function (Request $request) {
    return Limit::perMinute(5)->by($request->ip());
});

// routes/api.php
Route::middleware('throttle:auth')->group(function () {
    Route::post('login', [AuthController::class, 'login']);
});

Route::middleware(['auth:sanctum', 'throttle:api'])->group(function () {
    // Protected routes
});

Rate Limit Headers

The response includes:

  • X-RateLimit-Limit: Max requests allowed
  • X-RateLimit-Remaining: Remaining requests
  • Retry-After: Seconds until limit resets (when exceeded)

Testing

API Test Setup

<?php

namespace Tests\Feature\Api;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;

abstract class ApiTestCase extends TestCase
{
    use RefreshDatabase;

    protected User $user;

    protected function setUp(): void
    {
        parent::setUp();

        $this->user = User::factory()->create();
    }

    protected function actingAsApiUser(?User $user = null): static
    {
        Sanctum::actingAs($user ?? $this->user);

        return $this;
    }
}

Testing Endpoints

<?php

namespace Tests\Feature\Api;

use App\Models\Client;

class ClientApiTest extends ApiTestCase
{
    public function test_can_list_clients(): void
    {
        Client::factory()->count(5)->create();

        $response = $this->actingAsApiUser()
            ->getJson('/api/v1/clients');

        $response->assertOk()
            ->assertJsonStructure([
                'success',
                'data' => [
                    '*' => ['id', 'company_name', 'email'],
                ],
                'meta' => ['total', 'per_page'],
            ]);
    }

    public function test_can_create_client(): void
    {
        $data = [
            'company_name' => 'Acme Inc',
            'contact_name' => 'John Doe',
            'email' => 'john@acme.com',
        ];

        $response = $this->actingAsApiUser()
            ->postJson('/api/v1/clients', $data);

        $response->assertCreated()
            ->assertJsonPath('success', true)
            ->assertJsonPath('data.company_name', 'Acme Inc');

        $this->assertDatabaseHas('clients', ['email' => 'john@acme.com']);
    }

    public function test_validation_errors_returned_correctly(): void
    {
        $response = $this->actingAsApiUser()
            ->postJson('/api/v1/clients', []);

        $response->assertUnprocessable()
            ->assertJsonPath('success', false)
            ->assertJsonValidationErrors(['company_name', 'email']);
    }

    public function test_unauthenticated_request_returns_401(): void
    {
        $response = $this->getJson('/api/v1/clients');

        $response->assertUnauthorized()
            ->assertJsonPath('success', false);
    }

    public function test_unauthorized_request_returns_403(): void
    {
        $client = Client::factory()->create();

        $response = $this->actingAsApiUser()
            ->deleteJson("/api/v1/clients/{$client->id}");

        $response->assertForbidden();
    }
}

Testing Rate Limits

public function test_rate_limit_applied(): void
{
    for ($i = 0; $i < 60; $i++) {
        $this->actingAsApiUser()->getJson('/api/v1/clients');
    }

    $response = $this->actingAsApiUser()->getJson('/api/v1/clients');

    $response->assertStatus(429)
        ->assertJsonPath('message', 'Too many requests. Please try again later.');
}

Best Practices

Do

  • Use proper HTTP status codes
  • Return consistent response structure
  • Validate all inputs
  • Use API Resources for responses
  • Version your API
  • Implement rate limiting
  • Document all endpoints
  • Test thoroughly

Don't

  • Return HTML from API endpoints
  • Use 200 for all responses
  • Expose internal errors in production
  • Skip authentication/authorization
  • Allow unlimited request rates
  • Break backwards compatibility

API Explorer

The API includes interactive tools for exploring and testing endpoints.

Swagger UI

# Web UI
https://your-domain.com/api/docs

# OpenAPI Spec (JSON)
https://your-domain.com/api/docs/spec

API Playground

https://admin.your-domain.com/api-playground

The API Playground provides an enhanced testing experience with:

Endpoint Information:

  • Authentication type badges (API Key, Session, Either, Public)
  • Role requirement badges (Admin, Client, Any Authenticated)
  • Grouped by category (Profile, Projects, Invoices, etc.)

Request Building:

  • Method and URL builder
  • API key selection from available keys
  • Custom headers
  • JSON body editor with "Load Sample" button
  • Query parameters

Sample Data:

  • Pre-defined sample request bodies for POST/PUT/PATCH endpoints
  • Expected response format preview
  • Code examples in cURL, JavaScript, PHP, and Python

Response Viewing:

  • Response body with JSON formatting
  • Response headers
  • Status code and timing information

Endpoint Configuration

Endpoint metadata is defined in config/api-endpoints.php:

[
    'path' => '/api/v1/projects',
    'method' => 'POST',
    'summary' => 'Create a new project',
    'auth_type' => 'api_key', // api_key, session, either, none
    'required_roles' => ['admin'], // or [], ['client'], ['admin', 'client']
    'sample_request' => [
        'client_id' => 1,
        'name' => 'New Project',
    ],
    'sample_response' => [
        'data' => ['id' => 1, 'name' => 'New Project'],
    ],
    'tags' => ['Projects'],
]

SDK Generation

Generate client SDKs for multiple programming languages.

Command Line Usage

# Generate TypeScript SDK
php artisan api:generate-sdk typescript

# Generate Python SDK
php artisan api:generate-sdk python

# Supported languages
php artisan api:generate-sdk typescript  # TypeScript with axios
php artisan api:generate-sdk javascript  # JavaScript
php artisan api:generate-sdk php         # PHP with Guzzle
php artisan api:generate-sdk python      # Python
php artisan api:generate-sdk ruby        # Ruby
php artisan api:generate-sdk go          # Go
php artisan api:generate-sdk java        # Java
php artisan api:generate-sdk csharp      # C#/.NET

# Options
php artisan api:generate-sdk typescript --spec-only          # Only generate OpenAPI spec
php artisan api:generate-sdk typescript --output=/custom/path # Custom output directory
php artisan api:generate-sdk typescript --package-name=my-sdk # Custom package name

Web UI

Admins can access the SDK generator at:

https://your-domain.com/admin/api-sdk

Features:

  • Generate SDKs for any supported language
  • Download as ZIP archive
  • Regenerate OpenAPI spec
  • View generation status

Requirements

SDK generation requires the OpenAPI Generator CLI:

npm install -g @openapitools/openapi-generator-cli

API Analytics

Monitor API usage with the built-in analytics dashboard.

Accessing Analytics

https://your-domain.com/admin/api-analytics

Metrics Tracked

  • Total requests
  • Success/failure rates
  • Response times (average, max)
  • Requests by endpoint
  • Requests by HTTP method
  • Error rates by status code
  • Top API consumers
  • Daily/hourly trends

Middleware

API requests are automatically logged via the LogApiUsage middleware:

// routes/api.php
Route::prefix('v1')->middleware(['api.version', 'api.log'])->group(function () {
    // All routes in this group are logged
});

Data Retention

Configure log retention in your scheduled tasks:

// app/Console/Kernel.php
$schedule->command('model:prune', [
    '--model' => ApiUsageLog::class,
])->daily();

// app/Models/ApiUsageLog.php
protected function prunable(): Builder
{
    return static::where('created_at', '<', now()->subDays(90));
}

API Debug Logging

For detailed request/response debugging during development and troubleshooting.

Enabling Debug Logging

Debug logging can be enabled/disabled by admins via:

  • Admin UI: /admin/api-debug-logs - Toggle button
  • System Setting: api_debug_logging_enabled

What Gets Logged

When enabled, the debug logger captures:

  • Full request URL and method
  • Request headers (sanitized)
  • Request body (with sensitive fields redacted)
  • Query parameters
  • Response status and headers
  • Response body (truncated to 100KB)
  • Response time
  • User/API key information
  • Error details for failed requests

Viewing Debug Logs

Access debug logs at:

https://admin.your-domain.com/api-debug-logs

Features:

  • Filter by status (success/failed)
  • Filter by HTTP method
  • Filter by endpoint pattern
  • Filter by date range
  • Filter by user or API key
  • View full request/response details
  • Bulk delete old logs
  • Clear all logs

Sensitive Data Handling

The middleware automatically redacts sensitive data:

Headers removed:

  • Authorization
  • Cookie
  • X-API-KEY

Body fields redacted:

  • password, password_confirmation
  • token, access_token, refresh_token
  • secret, api_secret, client_secret
  • credit_card, card_number, cvv
  • ssn, social_security

Configuration

// config/api-endpoints.php
// Contains endpoint metadata used by API Playground

// Key system settings:
// api_debug_logging_enabled (boolean) - Enable/disable debug logging

Middleware

The debug logging middleware (api.debug.log) is automatically applied to API v1 routes:

// routes/api.php
Route::prefix('v1')
    ->middleware(['api.version', 'api.log', 'api.debug.log'])
    ->group(function () {
        // Routes here are debug logged when enabled
    });

API Versioning

Version Header

The API automatically adds version headers to responses:

  • X-API-Version: Current API version
  • X-API-Deprecated: Whether version is deprecated

Requesting Specific Versions

# Via URL prefix
GET /api/v1/clients

# Via header
GET /api/clients
X-API-Version: v1

# Via Accept header
GET /api/clients
Accept: application/vnd.clientportal.v1+json

Query Convention Extensions (Plan 266+)

The /api/v1/admin/audit-* namespace introduced a stricter query convention via the AppliesQueryParams trait. These conventions are documented here for reuse by future namespaces:

CSV filter normalization

When a filter accepts comma-separated values (e.g. filter[status]=draft,active):

  • Whitespace around segments is trimmed.
  • Duplicate values are deduplicated.
  • Empty segments from trailing commas are dropped silently.
  • Maximum 50 values per csv filter — over that → 422.

meta.truncated_includes

When a nested include (e.g. ?include=sections.items) is capped at 50 rows per parent, the response payload includes meta.truncated_includes: [...] containing the truncated include paths. Consumers needing the full collection should hit the dedicated child endpoint instead.

Date filter timezone behaviour

For <field>_after and <field>_before filters:

  • ISO 8601 inputs with timezone are honoured as-is.
  • Bare dates (2026-05-18) resolve to config('app.timezone') midnight start-of-day — NOT UTC midnight.
  • DB columns store UTC; the trait normalizes via CarbonImmutable::parse($input, config('app.timezone'))->utc().
  • Bounds are inclusive on both sides (>= for _after, <= for _before).

trashed query parameter

For endpoints whose model uses SoftDeletes:

  • trashed=without (default) — WHERE deleted_at IS NULL.
  • trashed=with — interleaved with soft-deleted rows.
  • trashed=only — soft-deleted rows only.

Every trashed=with|only request writes one AdminAuditLog entry (action: api.audit.trashed_query).