specialtactics/l5-api
Specialtactics/l5-api is a Laravel 5 package for building consistent JSON APIs with less boilerplate. It provides API controller foundations, standardized success and error responses, validation and exception handling, and helpers for cleaner RESTful endpoints.
1. Install
composer require specialtactics/l5-api
On Laravel 5.5+ the service provider is auto-discovered. On 5.4 and below, register it manually in config/app.php:
'providers' => [
// ...
Specialtactics\L5Api\Providers\L5ApiServiceProvider::class,
],
2. Publish the config (status codes, headers, error envelope):
php artisan vendor:publish --provider="Specialtactics\L5Api\Providers\L5ApiServiceProvider"
3. Look at these first, in order
config/l5-api.php (or config/api.php) — default status codes, error format, headers.src/Http/Controllers/ApiController.php — the base class your controllers will extend. Read the response method signatures before writing anything.4. First use case: a read endpoint
namespace App\Http\Controllers\Api;
use App\Models\Post;
use Specialtactics\L5Api\Http\Controllers\ApiController;
class PostsController extends ApiController
{
public function show($id)
{
$post = Post::findOrFail($id);
return $this->respondWithItem($post);
}
public function index()
{
return $this->respondWithCollection(Post::paginate(20));
}
}
Register a versioned route group (routes/api.php or Route::prefix('v1')) and hit it with Accept: application/json. If you get HTML back, the request isn't being negotiated as JSON — see Gotchas.
Extend the base controller once, app-wide. Don't have every controller extend the vendor class directly. Create app/Http/Controllers/Api/ApiController.php extending Specialtactics\L5Api\Http\Controllers\ApiController and put your envelope tweaks, common headers, and auth helpers there. This is your upgrade buffer if the package stalls.
Items vs collections vs arrays. Use respondWithItem() for a single resource, respondWithCollection() for lists (it handles paginator metadata if you pass a paginator), and respondWithArray() when you need a custom shape. Prefer the first two so the client sees one consistent envelope.
Transformers for output shaping. If the package wires up Fractal transformers, use them instead of ->toArray() on models — it keeps DB columns out of your public contract and lets you add fields without breaking consumers:
return $this->respondWithItem($post, new PostTransformer());
return $this->respondWithCollection($posts, new PostTransformer());
Status codes: set, then respond. Set the code before the response call and return the result:
public function store(StorePostRequest $request)
{
$post = Post::create($request->validated());
return $this->setStatusCode(201)->respondWithItem($post, new PostTransformer());
}
Use 204 + the no-content helper for deletes, not an empty 200 body — clients and HTTP caches differentiate.
Centralized error handling. The real value here is one error shape everywhere. Throw standard Laravel/Symfony exceptions (ModelNotFoundException, AuthorizationException, ValidationException) from services or controllers and let the package's handler convert them. Keep your app/Exceptions/Handler.php render() method delegating to the package for API requests and falling back to the HTML renderer for web routes:
public function render($request, Exception $e)
{
if ($request->expectsJson()) {
return $this->renderApiException($e); // package helper / your wrapper
}
return parent::render($request, $e);
}
Validation responses. Type-hint a FormRequest on the controller method and let the exception fire — the handler produces the 422 envelope. Never hand-roll response()->json(['errors' => ...]); you'll drift from the contract.
Headers and caching. Set custom headers (rate-limit info, ETag, deprecation notices) once per request via the controller's header setter rather than appending to each response. For public GETs, add Cache-Control middleware at the route level so responses stay cacheable through your CDN.
Testing pattern. Assert on structure, not exact JSON, so meta fields can evolve:
$this->getJson('/api/v1/posts/1')
->assertStatus(200)
->assertJsonStructure(['data' => ['id', 'title']]);
Also test the 404, 422, and 401 paths — the error envelope is the part most likely to regress.
register()/boot() don't call removed framework APIs. If you're on a modern Laravel, budget an afternoon to fork or vendor the base controller.Accept: application/json is mandatory for the JSON error path. Curl requests and browser hits without that header fall through to Laravel's HTML exception renderer and you'll spend an hour debugging a "missing" envelope. Tests using getJson() are fine.APP_DEBUG=true leaks stack traces into the error payload. Test error responses with debug off, and confirm the handler doesn't substitute the trace for your configured message.return. respondWithError() builds a response; if you call it without returning (or throwing), execution continues and you can end up with two responses or a 200 on a failure path.201 early and then returns a different response, the code can carry over within that request. Set the status immediately before the response call, and never set it inside a loop.respondWithCollection if the signature expects a collection — check src/ for the exact expected type. Wrapping the wrong object yields a data array that silently drops meta/links, and pagination breaks in the client.parseIncludes() call — they don't work automatically. If ?include=author returns nothing, that's why.php artisan config:cache after editing the package config; stale cached config is the usual cause of "my status code change did nothing."respondWithArray() in your app-level base controller to change the envelope instead of editing vendor/specialtactics/. Same for the exception handler.ApiController and the exception handler into app/ and drop the dependency. You lose little.Log::debug(get_class($response), [$response->getData(true)])) inside your base controller's response override. It's faster than eyeballing nested JSON diffs in test output.How can I help you explore Laravel packages today?