Installation:
composer require open-southeners/laravel-apiable
php artisan apiable:install
The apiable:install command publishes the config file and creates a default ApiableServiceProvider.
Initial Setup:
config/apiable.php (e.g., default namespace, JSON:API version).config/app.php under providers:
OpenSoutheners\Apiable\ApiableServiceProvider::class,
First Use Case:
Create a JSON:API resource for a Post model:
php artisan make:apiable Post
This generates:
app/Http/Resources/PostResource.php (default JSON:API resource).app/Http/Resources/PostCollection.php (for paginated responses).Use it in a controller:
use App\Http\Resources\PostResource;
public function show(Post $post)
{
return new PostResource($post);
}
Key Files to Review:
config/apiable.php: Global settings (e.g., api_version, default_namespace).app/Http/Resources/: Auto-generated resource classes.app/Providers/ApiableServiceProvider.php: Custom logic (e.g., global resource macros, model→resource mappings).First Filter (4.5.0): Add a typed, allow-listed filter to an endpoint and immediately get both positive and negated operators:
use OpenSoutheners\Apiable\Filters\AllowedFilter;
AllowedFilter::exact('status');
AllowedFilter::notEqual('status'); // ?filter[status][not_equal]=draft
AllowedFilter::like('title');
AllowedFilter::notLike('title'); // ?filter[title][not_like]=spam
Resource Creation:
make:apiable for models or manually extend OpenSoutheners\Apiable\Http\Resources\JsonApiResource.namespace App\Http\Resources;
use OpenSoutheners\Apiable\Http\Resources\JsonApiResource;
class CustomPostResource extends JsonApiResource
{
public function toArray($request)
{
return [
'id' => $this->id,
'title' => $this->title,
'custom_field' => $this->customField, // Override default fields
];
}
}
Mapping Models to Resources (recommended in 4.5.0):
// ApiableServiceProvider::boot()
Apiable::modelResourceMap([
\App\Models\Post::class => \App\Http\Resources\PostResource::class,
\App\Models\User::class => \App\Http\Resources\UserResource::class,
]);
Relationships:
public function toArray($request)
{
return [
'id' => $this->id,
'author' => new UserResource($this->whenLoaded('author')),
];
}
whenLoaded() to avoid N+1 queries.Collections:
JsonApiResourceCollection for paginated responses:
namespace App\Http\Resources;
use OpenSoutheners\Apiable\Http\Resources\JsonApiResourceCollection;
class PostCollection extends JsonApiResourceCollection
{
public function toArray($request)
{
return [
'data' => $this->collection,
'meta' => ['total' => $this->collection->total()],
];
}
}
Apiable::modelResourceMap() pick the per-model resource. Reserve custom collection classes for bespoke meta/links shapes.Filtering (4.5.0):
// Controller attribute
#[AllowedFilter::exact('status')]
public function index() { /* ... */ }
// Fluent, at the query/resource layer
Post::filter(AllowedFilter::exact('status'), AllowedFilter::notLike('title'));
AllowedFilter::notEqual('status') → excludes matching values.AllowedFilter::notLike('title') → excludes pattern matches.AND, so ?filter[status][not_equal]=draft,archived means status is not draft AND not archived (an intersection, not a union). Contrast this with positive lists, which typically broaden the result set — document the asymmetry for API consumers.equal + not_equal + like on title is legal), which is exactly what makes idempotent merging necessary.Sparse Fieldsets:
GET /api/v1/posts?fields[posts]=title,author
attributes. Explicitly requested appends remain visible.->addSelect('id', 'post_id') workarounds to make fields[...] + include play nicely together.API Versioning:
config/apiable.php:
'api_version' => 'v1',
ApiableVersionMiddleware).Macros and Extensions:
ApiableServiceProvider:
\OpenSoutheners\Apiable\Http\Resources\JsonApiResource::macro('addMeta', function ($key, $value) {
$this->additional['meta'][$key] = $value;
return $this;
});
return (new PostResource($post))->addMeta('status', 'published');
Error Handling:
ApiableServiceProvider:
\OpenSoutheners\Apiable\Exceptions\Handler::render($request, throwable: $exception);
Namespace Conflicts:
default_namespace in config/apiable.php matches your app/Http/Resources path.'default_namespace' => 'App\Http\Resources'.Relationship Loading:
with() in queries or whenLoaded() in resources:
// Bad: Loads all relationships unconditionally
$posts = Post::with('author', 'comments')->get();
// Good: Load only requested relationships
$posts = Post::query();
if ($request->has('include')) {
$posts->with(explode(',', $request->input('include')));
}
Sparse Fieldsets + Relationships (fixed in 4.5.0):
fields[...] with include. Upgrade if you relied on brittle manual select() calls to work around it.id/foreign keys to your toArray() "just to be safe" — they will leak into attributes and violate the sparse-fieldset contract clients rely on.Mixed-Model Collections:
modelResourceMap() and fell back to a single resource class, so heterogeneous payloads serialized incorrectly. Audit any feed/search/activity endpoints that were hand-coercing resources; the mapping now applies automatically.Default Filter Duplication (fixed in 4.5.0):
Negated Filter Semantics:
AND-combined. Don't assume array-like "any of" behavior; not_equal=a,b is an exclusion of both, and combining it with equal=c yields a narrower set than callers often expect.not_like respects your underlying LIKE wildcard conventions; document whether callers should pass % themselves in your API reference.Pagination Meta:
links, meta) may not match your API design. Override in PostCollection:
public function toArray($request)
{
return [
'data' => $this->collection,
'links' => [], // Customize or remove
'meta' => ['custom_meta' => 'value'],
];
}
Caching Resources:
toArray():
public function toArray($request)
{
return Cache::remember("post.{$this->id}", now()->addMinutes(10), function () {
return [
'id' => $this->id,
'title' => $this->title,
];
});
}
fields and include set, otherwise sparse responses poison the cache for full responses.Middleware Order:
ApiableVersionMiddleware runs before ApiableMiddleware in app/Http/Kernel.php:
protected $middleware = [
\OpenSoutheners\Apiable\Http\Middleware\ApiableVersionMiddleware::class,
\OpenSoutheners\Apiable\Http\Middleware\ApiableMiddleware::class,
];
Testing:
JsonApiTestCase for assertions:
use OpenSoutheners\Apiable\Testing\JsonApiTestCase;
class PostTest extends JsonApiTestCase
{
public function test_post_show()
{
$response = $this->getJson('/api/v1/posts/1');
$response->assertJsonApiResource('posts');
}
public function test_negated_filter_excludes_values()
{
$this->getJson('/api/v1/posts?filter[status][not_equal]=draft,archived')
->assertJsonMissing(['status' => 'draft']);
}
}
fields[] with include — that combination is exactly what 4.5.0 fixed, and it's easy to re-break with custom select() scopes.Enable JSON:API Debugging:
'debug' => true in config/apiable.php to log resource processing.Check Resource Output:
dd($resource->resolve($request)) to inspect raw resource data before serialization.Inspect Generated Query for Filters:
->toSql() / DB::listen() on a filtered query. An empty where-clause with an active filter almost always means a duplicate default declaration or a typo in the operator name.Validate Requests:
Accept: application/vnd.api+json header is present. Add middleware to enforce:
$request->headers->set('Accept', 'application/vnd.api+json');
Common Errors:
Class not found: Verify the resource class exists and namespace is correct.Method not allowed: Ensure routes use ApiableRouteMiddleware.data key: Confirm the resource extends JsonApiResource and returns an array.fields[]: Verify you're on ≥ 4.5.0, and that no custom select() pruned the key columns.Custom Serializers:
serializeRelationship() for custom relationship handling:
protected function serializeRelationship($name, $value, $request)
{
if ($name === 'author') {
return new UserResource($value);
}
return parent::serializeRelationship($name, $value, $request);
}
Dynamic Attributes:
mergeWhen() to conditionally add fields:
public function toArray($request)
{
return $this->mergeWhen(
$request->user()->can('view_hidden'),
['hidden_field' => $this->hiddenField]
);
}
mergeWhen() fields count as explicitly requested appends and remain visible under sparse fieldsets — useful for permission-gated attributes.Custom Filter Operators:
API Documentation:
/**
* @OA\Get(
* path="/api/v1/posts/{id}",
* @OA\Response(response="200", description="Returns a post")
* )
*/
AND semantics explicitly — they're the most likely source of consumer confusion.GraphQL-like Includes:
include parameter to load nested relationships dynamically:
public function toArray($request)
{
$includes = explode(',', $request->input('include', ''));
if (in_array('author', $includes)) {
$this->load('author');
}
return [...];
}
Custom Headers:
withResponse():
return (new PostResource($post))
->withResponse($request, function ($response) {
$response->header('X-Custom-Header', 'value');
});
How can I help you explore Laravel packages today?