markup/contentful
A Laravel package for integrating Contentful into your applications, providing a convenient bridge to fetch and work with content from the Contentful CMS API. A lightweight option for teams already using Contentful who want a simple PHP/Laravel integration layer, though it has a small community and...
composer require markup/contentful
You need two things from Contentful before writing any code: a space ID and a Content Delivery API token (Settings → API keys → "Content delivery / preview token"). This package talks to the read-only CDA — not the Management API, not the GraphQL endpoint.
First place to look is the README on the repo, then src/ for the client class and the Resource/Entry/Asset types it returns. The package is small; reading Contentful + Configuration + Entry covers 90% of the surface area.
Minimal bootstrap:
use Markup\Contentful\Contentful;
use Markup\Contentful\Configuration;
$contentful = new Contentful(
new Configuration('your_space_id', 'your_cda_token')
);
// First use case: list a content type and read a field
foreach ($contentful->getEntries(['content_type' => 'blogPost', 'limit' => 10]) as $entry) {
echo $entry->get('title'); // field value
echo $entry->get('slug');
}
Method names (
getEntries,getEntry,getAsset) are the idiomatic shape for this family of clients but confirm against the README of the tag you install — the package has been dormant since April 2018 and the docs are the only contract you get.
Single lookups by ID:
$entry = $contentful->getEntry('2kY6...ID...');
$asset = $contentful->getAsset('4aQ...ID...');
That's enough to render a page. Everything below is about not regretting it six months later.
Register it once, inject it everywhere. In Laravel, bind the client as a singleton in a service provider so you're not rebuilding a Guzzle stack per request:
$this->app->singleton(Contentful::class, fn () => new Contentful(
new Configuration(config('services.contentful.space'), config('services.contentful.token'))
));
There is no official service provider in the core package — don't go looking for one and don't composer require a random third-party wrapper to get it.
Wrap it in repositories, not controllers. The client returns raw Contentful resources whose get() is stringly-typed and returns null for missing fields. Map entries into your own DTOs immediately:
final class BlogPostRepository
{
public function __construct(private Contentful $client) {}
public function findBySlug(string $slug): ?BlogPost
{
$entries = $this->client->getEntries([
'content_type' => 'blogPost',
'fields.slug' => $slug,
'limit' => 1,
]);
$entry = $entries->first(); // or iterate — check the collection API
return $entry ? BlogPost::fromEntry($entry) : null;
}
}
This gives you one place to add caching, one place to fix field renames, and it keeps Markup\Contentful\* types out of your Blade templates and API resources.
Use query parameters aggressively. The CDA is a query API; pushing work server-side is cheaper than filtering in PHP:
content_type — required for most list queriesfields.slug / fields.category.sys.id — field-level filtersinclude — link resolution depth (default 1, max 10)select — return only the fields you need (cuts payload substantially)order — e.g. -sys.createdAtlimit / skip — paginationlocale — override the default localePagination. The response envelope carries total, skip, and limit. Loop until you've collected total items; do not assume the first page is everything:
$skip = 0;
$limit = 100;
do {
$page = $contentful->getEntries(compact('limit', 'skip') + ['content_type' => 'blogPost']);
foreach ($page as $entry) { /* ... */ }
$skip += $limit;
} while ($skip < $page->getTotal());
Cache by query signature, not by URL. Hash the parameter array plus the locale and store the mapped DTOs. If you're mapping to BlogPost objects, serialize those, not the Contentful resources — the resources hold lazy links that won't survive serialize() cleanly.
Rich text is not HTML. Contentful's rich text field comes back as a structured JSON document (nodeType: "document" with nested paragraphs, marks, and embedded entry links). This package will hand you that structure raw. You need your own walker to convert it to HTML or to render it in Blade. Budget a day for it, or add a dedicated rich-text renderer. Don't expect a ->toHtml() on the entry.
Preview before publish. For drafts, point Configuration at preview.contentful.com with the preview token. Keep the preview client in a separate binding so a misconfigured env var can never leak drafts onto the production site.
Testing. Don't hit the live API in CI. Either stub the client interface/class in your repository tests (that's the payoff for wrapping it), or record a handful of real JSON fixtures and feed them to the resource factories if the package exposes them.
Sync for bulk invalidation. The CDA has a /sync endpoint that returns only changed entries since a token. This package almost certainly doesn't expose it (verify — it's from 2018). If you need it, call it through the same Guzzle instance or use webhooks + a sys.updatedAt check instead.
It's effectively unmaintained. Last release April 2018, 12 stars, zero dependents. That's fine — it's a thin HTTP wrapper — but treat it as vendored code you own. Pin the version, and be prepared to fork it if a Guzzle major version bumps you. Audit its Composer constraints against your PHP version before committing to it in a greenfield PHP 8.3 app.
Lazy links cause N+1 fetches. If an entry has a link to another entry and it wasn't resolved in the original response, accessing it may trigger a fresh HTTP request — per link, per iteration, inside your Blade loop. This is the single biggest performance trap. Fix it with include=2 (or higher) on the initial query, or resolve in bulk yourself. Watch your logs for a burst of /entries/{id} calls on one page render.
include maxes out at 10 on Contentful's side. If your content graph is deeper than that, you're doing a second query; design your content model so it isn't.
Missing fields return null, not an exception. A typo in a field name ('titel') silently gives you null and an empty heading on the page. Validate field names once, in your DTO factory, and throw there:
$title = $entry->get('title') ?? throw new \RuntimeException("blogPost missing title: {$entry->getId()}");
Locale fallback is not automatic. Contentful supports fallback chains, but the raw response only gives you the requested locale's values. If you support multiple locales, request the fallback locale or configure the chain on the space and verify behavior — easier to detect this in staging than in production.
Rate limits are real. Contentful throttles per space (~10 requests/second sustained, with bursts) and returns 429 with an X-Contentful-RateLimit-Reset header. A 2018 client very likely has no retry/backoff. If you're doing any batch work — sitemaps, reindexing, static builds — add your own retry loop around the calls and sleep when you see a 429.
Asset URLs come back protocol-relative. file.url is typically //images.ctfassets.net/.... Prefix https: before emitting it into src= / og:image, or your CDN and social previews will break.
Environment support. Multiple environments (beyond master) shipped in 2017, right around this package's age. Check whether Configuration accepts an environment parameter; if it doesn't, you're pinned to master or patching the query builder. This matters if you use staging spaces.
Debugging: log the raw decoded JSON for failing queries — the resource wrapper's get() hides the actual shape, and "field is null" is almost always "the query didn't match what you thought" (wrong content_type, wrong fields. prefix, or you queried a localization-suffixed field name).
Extension points are thin. If the package exposes a factory or resource-class registry, that's where you'd hook custom types; otherwise subclass the client and override the entry factory. Keep your extensions in one Contentful/ namespace so a future migration to contentful/contentful.php (the official SDK) is a directory delete rather than an archaeology project.
Don't serialize() entries into cache. Lazy link objects and Guzzle dependencies don't round-trip. Cache your mapped DTOs or the raw JSON body, rehydrate by re-querying with select limited to the fields you need.
How can I help you explore Laravel packages today?