dgural / laravel-strapi
Elegant Laravel integration for Strapi CMS with model-based API access
Requires
- php: ^8.3
- illuminate/database: ^11.0|^12.0
- illuminate/http: ^11.0|^12.0
- illuminate/pagination: ^11.0|^12.0
- illuminate/support: ^11.0|^12.0
- nesbot/carbon: ^3.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- pestphp/pest: ^3.0
This package is auto-updated.
Last update: 2026-04-18 18:00:10 UTC
README
A Laravel package for integrating with Strapi 5 CMS using an Eloquent-like model layer.
Instead of manually calling HTTP endpoints, you define model classes that map to Strapi content types and query them with a fluent builder — the same way you would with Eloquent.
Requires: PHP 8.3+, Laravel 11+, Strapi 5
Installation
composer require dgural/laravel-strapi
The service provider is registered automatically via Laravel's package auto-discovery. If you have disabled auto-discovery, add it manually to bootstrap/providers.php:
return [ // ... DGCode\Strapi\StrapiServiceProvider::class, ];
Publish the config file:
php artisan vendor:publish --tag=strapi-config
This creates config/strapi.php in your application. Add the following to your .env:
STRAPI_BASE_URL=https://your-strapi-url.com STRAPI_TOKEN=your-api-token # Optional caching STRAPI_CACHE_ACTIVE=true STRAPI_CACHE_TTL=3600
Defining Models
Generate a model with Artisan:
# Collection Type (default) php artisan make:strapi-model Post # Single Type php artisan make:strapi-model Homepage --single
This creates app/Strapi/Models/Post.php:
<?php namespace App\Strapi\Models; use DGCode\Strapi\StrapiModel; class Post extends StrapiModel { // Strapi content type API ID — auto-derived from class name if omitted // Post → 'posts', BlogPost → 'blog-posts' protected static string $contentType = 'posts'; // 'collection' or 'single' protected static string $type = 'collection'; // Standard Eloquent casts — all built-in cast types work protected $casts = [ 'publishedAt' => 'datetime', 'publishedAt' => 'datetime:Y-m-d', // with explicit format 'viewCount' => 'integer', 'featured' => 'boolean', 'meta' => 'array', 'status' => StatusEnum::class, ]; // Relations to other Strapi content types protected array $strapiRelations = [ 'author' => Author::class, // has-one 'tags' => Tag::class, // has-many (auto-detected by indexed array) ]; // Override cache TTL for this model (null = use global config) protected static ?int $cacheTtl = null; // Default items per page for paginate() protected static int $perPage = 25; }
Casts
$casts is inherited directly from Eloquent — all standard cast types work:
| Cast | PHP type |
|---|---|
'integer' |
int |
'float' |
float |
'boolean' |
bool |
'string' |
string |
'array' |
array |
'collection' |
Illuminate\Support\Collection |
'datetime' |
Carbon\Carbon |
'datetime:Y-m-d' |
Carbon\Carbon (serializes with given format) |
'decimal:2' |
string |
AsEnum::class |
Backed enum |
Custom CastsAttributes |
Any type |
Strapi Relations
Relations to other Strapi content types are declared in $strapiRelations separately from $casts. This is necessary because Eloquent's cast system only handles scalar transformations — it has no concept of nested API objects.
protected array $strapiRelations = [ 'author' => Author::class, // has-one → Author instance 'tags' => Tag::class, // has-many → Collection of Tag instances ];
The type (has-one vs has-many) is detected automatically from the response shape. Relations are only hydrated if the relation data is included in the Strapi response — use ->populate() to request them:
Post::query()->populate(['author', 'tags'])->get(); $post->author; // Author instance $post->author->name; // string $post->tags; // Collection<Tag> $post->tags->first(); // Tag instance
Without ->populate(), relation fields will be null even if declared in $strapiRelations.
Querying
Fetching records
// Get all records $posts = Post::all(); // Fluent query builder $posts = Post::query() ->where('status', 'published') ->orderByDesc('publishedAt') ->get(); // Find by documentId $post = Post::find('clkgylmcc000008lcdd868feh'); $post = Post::findOrFail('clkgylmcc000008lcdd868feh'); // First matching record $post = Post::query()->where('slug', 'hello-world')->first(); $post = Post::query()->where('slug', 'hello-world')->firstOrFail();
Filtering
// Equality (default) ->where('status', 'published') // With Strapi operator ->where('viewCount', '$gte', 100) ->where('publishedAt', '$lt', '2025-01-01') // Other helpers ->whereIn('category', ['news', 'blog']) ->whereNull('archivedAt') ->whereNotNull('featuredImage')
Supported Strapi operators: $eq, $ne, $lt, $lte, $gt, $gte, $in, $notIn, $contains, $startsWith, $endsWith, and others from the Strapi Filters docs.
Populating relations
// Specific relations ->populate(['author', 'coverImage']) // With nested options ->populate([ 'channel' => [ 'populate' => [ 'thumbnail' => true, ], ], ]) // Everything ->populate('*')
Sorting & field selection
->orderBy('publishedAt') ->orderByDesc('publishedAt') // Limit returned fields (reduces response size) ->select(['title', 'slug', 'publishedAt'])
Localisation
->locale('de') ->locale('uk-UA')
Pagination
paginate() is compatible with Eloquent's signature:
$posts = Post::query() ->locale('uk-UA') ->orderByDesc('publishedAt') ->paginate(); // uses $perPage from model (default 25) $posts = Post::query()->paginate(10); // $perPage as closure — receives $total as argument $posts = Post::query()->paginate(fn ($total) => min($total, 100)); // Custom page name for the query string parameter $posts = Post::query()->paginate(pageName: 'p');
StrapiPaginator extends Illuminate\Pagination\LengthAwarePaginator — all standard Laravel pagination methods work:
$posts->items(); // array of model instances $posts->total(); // total number of records $posts->currentPage(); // current page number $posts->lastPage(); // total pages $posts->hasMorePages(); // bool $posts->nextPageUrl(); // ?string $posts->previousPageUrl(); // ?string
Blade rendering works without any additional setup:
{{ $posts->links() }}
toArray() includes both standard Laravel pagination keys and a Strapi-style meta.pagination block for API responses.
Offset-based pagination
// limit / offset — maps to Strapi's pagination[limit] / pagination[start] Post::query()->limit(10)->offset(20)->get();
limit and offset are mutually exclusive with paginate() / forPage() — calling one resets the other.
Single Types
$homepage = Homepage::query()->populate('*')->first(); echo $homepage->heroTitle;
Accessing Attributes
Each model exposes three system properties from the Strapi response:
$post->documentId // string — primary identifier (CUID), used for all API calls $post->id // ?int — numeric DB row ID, present for reference only $post->locale // ?string — locale of this instance, e.g. 'uk-UA'
Attribute access works the same as Eloquent:
$post = Post::find('abc123'); $post->title // raw string $post->publishedAt // Carbon instance (if cast) $post->publishedAt->diffForHumans() $post->author->name // related model (if declared in $strapiRelations) $post->getAttribute('title') // explicit getter $post->getAttributes() // all scalar attributes as array $post->toArray() // documentId + id + attributes + relations
Write Operations
Create
$post = Post::create([ 'title' => 'New Post', 'slug' => 'new-post', 'status' => 'draft', ]); // With locale Post::query()->locale('uk-UA')->create(['title' => 'Нова стаття']); // POST /api/posts?locale=uk-UA
Update
// Via instance — only sends dirty (changed) attributes $post->title = 'Updated Title'; $post->save(); // Via instance — fill and save $post->update(['title' => 'Updated Title', 'status' => 'published']); // Via static builder Post::query()->update('abc123', ['status' => 'published']);
If the model instance has a locale, it is passed automatically:
// $post->locale === 'uk-UA' (hydrated from Strapi response) $post->title = 'Оновлено'; $post->save(); // PUT /api/posts/{documentId}?locale=uk-UA
Delete
$post->delete(); // DELETE /api/posts/{documentId}?locale=uk-UA (if locale is set) Post::query()->delete('abc123'); Post::query()->locale('uk-UA')->delete('abc123');
Caching
Global cache settings are configured in config/strapi.php:
STRAPI_CACHE_ACTIVE=true STRAPI_CACHE_TTL=3600
Override per model:
class Post extends StrapiModel { protected static ?int $cacheTtl = 600; // 10 minutes for this model }
Override per query:
Post::query()->cache(300)->get(); // 5 minutes for this query Post::query()->noCache()->get(); // bypass cache for this query
Error Handling
| Exception | When |
|---|---|
StrapiNotFoundException |
404 response, or findOrFail() / firstOrFail() finds nothing |
StrapiAuthException |
401 / 403 response |
StrapiRequestException |
Any other non-2xx response |
use DGCode\Strapi\Exceptions\StrapiNotFoundException; use DGCode\Strapi\Exceptions\StrapiAuthException; use DGCode\Strapi\Exceptions\StrapiRequestException; try { $post = Post::findOrFail($documentId); } catch (StrapiNotFoundException $e) { abort(404); } catch (StrapiAuthException $e) { abort(403); }
Configuration Reference
// config/strapi.php return [ 'base_url' => env('STRAPI_BASE_URL', 'http://localhost:1337'), 'token' => env('STRAPI_TOKEN'), 'timeout' => env('STRAPI_TIMEOUT', 30), 'cache' => [ 'active' => env('STRAPI_CACHE_ACTIVE', false), 'ttl' => env('STRAPI_CACHE_TTL', 3600), ], ];
License
MIT