Skip to content

DmytroGural/laravel-strapi

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Laravel Strapi

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

About

Laravel client for Strapi API. The easiest way to consume Strapi headless CMS content in Laravel projects.

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages