These integration guides are not official documentation and the Strapi Support Team will not provide assistance with them.
What Is PHP?
PHP is a server-side scripting language. It has mature HTTP client libraries like Guzzle, and handles JSON parsing natively through json_decode. Modern PHP (8.1+) includes type declarations, enumerations, and match expressions that make API client code cleaner and more predictable.
For Strapi integration, PHP acts as the consuming layer — it sends HTTP requests to Strapi's REST API and renders the returned JSON content into web pages, feeds, or application views.
Sign up for the Logbook, Strapi's Monthly newsletter
Why Integrate PHP with Strapi
Pairing PHP with a headless Content Management System (CMS) like Strapi v5 gives you structured content management without rebuilding it inside your PHP codebase. Here's what that looks like in practice:
- Auto-generated APIs skip the scaffolding. Strapi v5 creates REST endpoints for every content type you define. PHP consumes them with standard HTTP — no custom API layer to build or maintain.
- Content modeling stays visual. The Content-Type Builder lets you define fields, relationships, and dynamic zones through a UI. When the model changes, the API updates automatically — no PHP-side schema migrations.
- Editors and developers work in parallel. Content editors manage entries through Strapi's admin panel while PHP developers query structured JSON. No content freezes, no deployment coordination for copy changes.
- Granular permissions control access. Strapi's role-based access control supports per-content-type CRUD permissions and scoped API tokens, so different PHP services or user tiers get different content access without custom middleware.
- Built-in i18n simplifies multilingual sites. Add
?locale=frto any REST request and get locale-specific content back. Strapi v5 includes internationalization in core — no extra plugins or PHP translation layers needed. - Draft and publish workflows via the same endpoint. Query
?status=publishedfor production pages and?status=draftfor preview modes using appropriately scoped tokens. No separate staging infrastructure required.
How to Integrate PHP with Strapi
This section covers everything from environment setup through full CRUD operations, error handling, and authentication.
Prerequisites
Before starting, make sure you have the following installed and configured:
| Tool | Version | Notes |
|---|---|---|
| PHP | 8.5+ | Current stable branch |
| Composer | Latest | PHP dependency manager |
| Node.js | 20.x, 22.x, or 24.x (LTS versions) | Required for the Strapi server |
| Guzzle | 7.10+ | PHP HTTP client |
| Strapi | 5.40.0 | Latest stable release |
Your composer.json should include:
{
"require": {
"php": "^8.4",
"guzzlehttp/guzzle": "^7.10"
}
}Install dependencies:
composer require guzzlehttp/guzzleAnd your Strapi project's package.json might look like this:
{
"dependencies": {
"@strapi/strapi": "5.40.0",
"@strapi/plugin-users-permissions": "5.40.0",
"@strapi/plugin-i18n": "5.40.0"
},
"engines": {
"node": "20.x || 22.x || 24.x"
}
}Step 1: Create a Strapi v5 Project
Start a fresh Strapi instance:
npx create-strapi@latest my-strapi-projectOnce installed, start the development server:
cd my-strapi-project
npm run developOpen http://localhost:1337/admin and register your first admin account. Then set up your content:
- Create content types via the Content-Type Builder in the Admin Panel
- Add entries through the Content Manager and publish them — unpublished content won't appear in API responses
- Set public permissions: navigate to Settings → Users & Permissions → Public role and enable
findandfindOnefor the content types you need - Generate an API token: go to Settings → Global Settings → API Tokens → Create new API Token, pick your token type (read-only, full access, or custom), and save. The token value appears once — copy it immediately
Strapi supports SQLite (default for development), PostgreSQL, MySQL, and MariaDB. For production with Strapi 5, the documentation lists MySQL 8.4 (minimum 8.0), MariaDB 11.4 (minimum 10.3), and PostgreSQL 17.0 (minimum 14.0) as supported database versions.
Step 2: Build the PHP Client
Set the Authorization header once at the client level so every request inherits it:
<?php
require 'vendor/autoload.php';
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\ConnectException;
class StrapiClient
{
private Client $client;
public function __construct(string $baseUrl, string $apiToken)
{
$this->client = new Client([
'base_uri' => rtrim($baseUrl, '/'),
'headers' => [
'Authorization' => 'Bearer ' . $apiToken,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
'timeout' => 10.0,
'http_errors' => true,
]);
}
public function getClient(): Client
{
return $this->client;
}
}
// Store STRAPI_API_TOKEN in your environment — never hardcode it
$strapi = new StrapiClient('http://localhost:1337', getenv('STRAPI_API_TOKEN'));The Authorization: Bearer syntax follows the format specified in the API tokens documentation. Read-only tokens can only access find and findOne — make sure your token type matches the operations you need. A POST or PUT with a read-only token returns a 403.
Guzzle's query option handles the bracket-notation encoding that Strapi's filter syntax requires, which saves you from manually constructing query strings.
Step 3: Fetch a Collection with Filtering and Pagination
Strapi v5 returns a flattened response format — attributes sit directly on each data object, with no attributes wrapper. That affects every line of PHP parsing code.
<?php
function getRestaurants(Client $client): array
{
$response = $client->request('GET', '/api/restaurants', [
'query' => [
'filters' => [
'Name' => ['$containsi' => 'paris'],
'stars' => ['$gte' => 3],
],
'sort' => ['createdAt:desc'],
'fields' => ['Name', 'Description', 'stars'],
'populate' => ['cover', 'chef'],
'pagination' => [
'page' => 1,
'pageSize' => 10,
],
'status' => 'published',
],
]);
$body = json_decode((string) $response->getBody(), true);
// v5: data is a flat array — access $body['data'][0]['Name'] directly
return [
'data' => $body['data'] ?? [],
'pagination' => $body['meta']['pagination'] ?? [],
];
}A critical point: Strapi v5 does not populate relations, media, components, or dynamic zones by default. If you skip the populate parameter, those relation, media, component, and dynamic zone fields are not returned at all. Fields such as coverImage, author, or category should be explicitly populated when you need them included in the API response.
Here's what the v5 response JSON looks like:
{
"data": [
{
"id": 2,
"documentId": "hgv1vny5cebq2l3czil1rpb3",
"Name": "BMK Paris Bamako",
"Description": null,
"createdAt": "2024-03-06T13:42:05.098Z",
"updatedAt": "2024-03-06T13:42:05.098Z",
"publishedAt": "2024-03-06T13:42:05.103Z",
"locale": "en"
}
],
"meta": {
"pagination": {
"page": 1,
"pageSize": 25,
"pageCount": 1,
"total": 2
}
}
}Notice the documentId string — that's your primary identifier for all subsequent operations. Not the integer id.
Step 4: Fetch a Single Document
Use the documentId string from API responses as the URL parameter — not the integer id. This is a v5 breaking change — all PHP code building endpoint URLs must use the documentId string value instead of the numeric id.
<?php
function getRestaurantByDocumentId(Client $client, string $documentId): array
{
$response = $client->request('GET', '/api/restaurants/' . $documentId, [
'query' => [
'populate' => ['cover', 'chef', 'categories'],
],
]);
$body = json_decode((string) $response->getBody(), true);
// v5: access as $restaurant['Name'], NOT $restaurant['attributes']['Name']
return $body['data'] ?? [];
}Step 5: Create a Document
Some Strapi v5 write-operation examples use a top-level data wrapper in the request body, but the REST API docs do not clearly state that it is required for all POST/PUT/PATCH requests. Strapi v5 uses a Rich Text (Blocks) editor that stores content as typed block nodes:
<?php
function createRestaurant(Client $client, array $data): array
{
$response = $client->request('POST', '/api/restaurants', [
'json' => [
'data' => $data,
],
]);
$body = json_decode((string) $response->getBody(), true);
// Store documentId for subsequent PUT/DELETE operations
return $body['data'] ?? [];
}
$newRestaurant = createRestaurant($strapi->getClient(), [
'Name' => 'Le Petit Bistro',
'Description' => [
[
'type' => 'paragraph',
'children' => [
['type' => 'text', 'text' => 'A cozy French bistro.'],
],
],
],
'stars' => 4,
]);
$documentId = $newRestaurant['documentId']; // Save this — you need it for updatesStep 6: Update and Delete Documents
Updates follow the same data wrapper pattern. Successful DELETE requests return HTTP 204 with no response body:
<?php
function updateRestaurant(Client $client, string $documentId, array $data): array
{
$response = $client->request('PUT', '/api/restaurants/' . $documentId, [
'json' => [
'data' => $data,
],
]);
$body = json_decode((string) $response->getBody(), true);
return $body['data'] ?? [];
}
function deleteRestaurant(Client $client, string $documentId): bool
{
$response = $client->request('DELETE', '/api/restaurants/' . $documentId);
return $response->getStatusCode() === 204;
}
// Update example
$updated = updateRestaurant($strapi->getClient(), 'hgv1vny5cebq2l3czil1rpb3', [
'Name' => 'BMK Paris Bamako (Updated)',
'stars' => 5,
]);
echo $updated['Name']; // v5: attribute directly on the data objectStep 7: Add Error Handling
Guzzle's exception hierarchy maps cleanly to Strapi's HTTP responses when Guzzle's http_errors option is enabled. ConnectException means the Strapi server is unreachable or another network error occurred; ClientException covers 4xx responses such as bad tokens or validation failures; ServerException handles 5xx responses from Strapi:
<?php
use GuzzleHttp\Client;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Exception\ServerException;
use GuzzleHttp\Exception\ConnectException;
function strapiRequest(Client $client, string $method, string $uri, array $options = []): array
{
try {
$response = $client->request($method, $uri, $options);
return json_decode((string) $response->getBody(), true);
} catch (ConnectException $e) {
throw new RuntimeException('Cannot connect to Strapi: ' . $e->getMessage());
} catch (ClientException $e) {
$statusCode = $e->getResponse()->getStatusCode();
$errorBody = json_decode((string) $e->getResponse()->getBody(), true);
$message = $errorBody['error']['message'] ?? 'Unknown client error';
return match($statusCode) {
400 => throw new InvalidArgumentException("Bad request (400): {$message}"),
401 => throw new RuntimeException("Unauthorized (401): check Bearer token"),
403 => throw new RuntimeException("Forbidden (403): insufficient permissions — {$message}"),
404 => throw new RuntimeException("Not found (404): {$uri}"),
422 => throw new InvalidArgumentException("Validation failed (422): {$message}"),
default => throw new RuntimeException("Client error ({$statusCode}): {$message}"),
};
} catch (ServerException $e) {
$statusCode = $e->getResponse()->getStatusCode();
throw new RuntimeException("Strapi server error ({$statusCode}): " . $e->getMessage());
} catch (\JsonException $e) {
throw new RuntimeException('Failed to parse Strapi response: ' . $e->getMessage());
}
}Strapi includes a human-readable error message in error.message and additional error details in error.details within the response body, which the 4xx handler extracts above.
Step 8: Implement JWT Authentication for User-Facing Apps
For user-context requests (not server-to-server), the Users and Permissions plugin provides a JWT flow:
<?php
use GuzzleHttp\Client;
function loginAndGetJwt(Client $unauthenticatedClient, string $identifier, string $password): string
{
$response = $unauthenticatedClient->request('POST', '/api/auth/local', [
'json' => [
'identifier' => $identifier,
'password' => $password,
],
]);
$body = json_decode((string) $response->getBody(), true);
return $body['jwt'] ?? throw new RuntimeException('No JWT in response');
}
function buildJwtClient(string $jwt, string $baseUrl): Client
{
return new Client([
'base_uri' => $baseUrl,
'headers' => [
'Authorization' => 'Bearer ' . $jwt,
'Content-Type' => 'application/json',
'Accept' => 'application/json',
],
]);
}API tokens are commonly used for authenticated access by applications, including server-side integrations. JWT authentication fits scenarios where individual users log in through your PHP frontend and need user-scoped content access.
Project Example: Developer Blog Powered by PHP and Strapi
This project ties together everything covered above — fetching collections, filtering by slug, populating relations and media, rendering Rich Text blocks, and handling pagination. The blog uses Strapi v5 as the content backend and plain PHP for routing and template rendering.
Content Type Setup
Create an Article Collection Type in Strapi's Content-Type Builder with these fields:
| Field | Type | Notes |
|---|---|---|
title | Text | Required |
slug | UID | Based on title |
content | Rich Text (Blocks) | Full body |
excerpt | Text | Max 200 characters |
coverImage | Media | Single image |
author | Relation | Many-to-one → User |
publishedAt | Datetime | Auto-managed |
After creating the content type, add a few articles through the Content Manager, publish them, and confirm the Public role has read access enabled for that collection type under Users & Permissions plugin → Roles.
Project Structure
php-blog/
├── public/
│ └── index.php
├── src/
│ └── StrapiClient.php
├── templates/
│ ├── layout.php
│ ├── blog-list.php
│ └── blog-post.php
├── .env
└── composer.jsonThe Strapi Client
This uses native cURL to keep dependencies minimal. Swap in Guzzle if your project already uses Composer:
<?php
class StrapiClient
{
private string $baseUrl;
private string $token;
public function __construct()
{
$this->baseUrl = $_ENV['STRAPI_URL'] ?? 'http://localhost:1337';
$this->token = $_ENV['STRAPI_API_TOKEN'] ?? '';
}
public function get(string $path): array
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $this->baseUrl . '/api/' . ltrim($path, '/'),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $this->token,
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode < 200 || $httpCode >= 300) {
return ['data' => [], 'meta' => []];
}
return json_decode($response, true);
}
}The .env file:
STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-api-token-hereFront Controller and Routing
The public/index.php handles two routes: a blog listing and individual post pages resolved by slug. Note the populate parameters — without them, coverImage and author come back empty:
<?php
require_once __DIR__ . '/../vendor/autoload.php';
require_once __DIR__ . '/../src/StrapiClient.php';
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$strapi = new StrapiClient();
if ($path === '/' || $path === '/blog') {
$result = $strapi->get(
'articles?populate[coverImage]=true&sort=publishedAt:desc&pagination[pageSize]=10'
);
$posts = $result['data'] ?? [];
require __DIR__ . '/../templates/blog-list.php';
} elseif (preg_match('#^/blog/([a-z0-9\-]+)$#', $path, $matches)) {
$slug = $matches[1];
$result = $strapi->get(
'articles?filters[slug][$eq]=' . urlencode($slug)
. '&populate[coverImage]=true&populate[author]=true'
);
$post = $result['data'][0] ?? null;
if (!$post) {
http_response_code(404);
echo "Not found";
exit;
}
require __DIR__ . '/../templates/blog-post.php';
} else {
http_response_code(404);
echo "Page not found";
}The sort and pagination parameters keep the listing page manageable. For a paginated archive, pass pagination[page] from a query string parameter and use meta.pagination.pageCount from the response to build page links.
Blog Listing Template
Every field access goes directly to the data object — $post['title'], not nested attribute wrappers. For populated relations and media fields, access depends on explicit population and the response structure returned by Strapi v5.
<?php require __DIR__ . '/layout.php'; ?>
<main>
<h1>Blog</h1>
<?php foreach ($posts as $post): ?>
<article>
<?php if (!empty($post['coverImage']['url'])): ?>
<img
src="<?= htmlspecialchars($post['coverImage']['url']) ?>"
alt="<?= htmlspecialchars($post['title']) ?>"
>
<?php endif; ?>
<h2>
<a href="/blog/<?= htmlspecialchars($post['slug']) ?>">
<?= htmlspecialchars($post['title']) ?>
</a>
</h2>
<p><?= htmlspecialchars($post['excerpt'] ?? '') ?></p>
<time datetime="<?= htmlspecialchars($post['publishedAt']) ?>">
<?= htmlspecialchars($post['publishedAt']) ?>
</time>
</article>
<?php endforeach; ?>
</main>Single Post Template with Rich Text Rendering
Strapi v5's Rich Text (Blocks) editor stores content as an array of typed block nodes. Each block has a type and a children array. You need to iterate through both to render the output:
<?php require __DIR__ . '/layout.php'; ?>
<article>
<h1><?= htmlspecialchars($post['title']) ?></h1>
<?php if (!empty($post['author']['username'])): ?>
<p>By <?= htmlspecialchars($post['author']['username']) ?></p>
<?php endif; ?>
<time><?= htmlspecialchars($post['publishedAt']) ?></time>
<?php if (!empty($post['coverImage']['url'])): ?>
<img src="<?= htmlspecialchars($post['coverImage']['url']) ?>" alt="">
<?php endif; ?>
<?php
// Rich Text (Blocks) = array of typed block nodes
foreach ($post['content'] ?? [] as $block) {
if ($block['type'] === 'paragraph') {
echo '<p>';
foreach ($block['children'] as $child) {
echo htmlspecialchars($child['text'] ?? '');
}
echo '</p>';
}
}
?>
</article>Strapi Open Office Hours
If you have any questions about Strapi 5 or just would like to stop by and say hi, you can join us at Strapi's Discord Open Office Hours, Monday through Friday, from 12:30 pm to 1:30 pm CST: Strapi Discord Open Office Hours.
For more details, visit the Strapi documentation and PHP documentation.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.FAQ
Can PHP use Strapi v5's GraphQL API instead of REST?
Yes. Strapi's GraphQL plugin exposes a /graphql endpoint that accepts standard HTTP POST requests with a JSON body. PHP queries it using Guzzle or cURL with the same Authorization: Bearer header as REST. The plugin auto-generates a schema for content types when shadowCRUD is enabled by default. In v5, GraphQL responses are flattened, so Relay-style queries use nodes and pageInfo instead of the older data.attributes structure.
How do you upload files from PHP to Strapi v5?
Send a multipart/form-data POST to /api/upload using cURL's CURLFile or Guzzle's multipart option. Strapi v5 treats file upload as a separate step from entry creation. Upload the file first to receive a file ID, then reference that ID when creating or updating a content entry. You can also include ref, refId, and field form-data fields to link the upload directly to a specific content type field during the upload request.
Is Strapi free for PHP developers to use?
Strapi Community Edition is free and open source under the MIT license. There are no restrictions based on the consuming language, so PHP applications use the same core platform as any other client. The free tier includes REST and GraphQL APIs, the Content-Type Builder, Media Library, RBAC, internationalization, and Draft and Publish. Paid plans add features like SSO, Audit Logs, and Review Workflows, but on Strapi these premium CMS features require a separate Growth or Enterprise CMS license rather than being included with Strapi Cloud alone.
What PHP frameworks work with Strapi v5?
Any PHP framework works because the integration is just HTTP requests to Strapi's API. Laravel has a community package that wraps collection and single-type queries, while Symfony developers can use the symfony/http-client component with Bearer token headers. CodeIgniter, Slim, and plain PHP work equally well with Guzzle or native cURL. Framework choice mainly affects the client implementation; Strapi's API behavior stays the same regardless of the PHP stack.
How do you deploy PHP and Strapi together in production?
Strapi runs on Node.js and PHP runs on its own server, so they communicate over HTTP as separate services. Docker Compose is one common local and self-hosted setup: define a strapi service and a database container, optionally adding a reverse proxy such as nginx if needed. On the internal Docker network, PHP reaches Strapi at http://strapi:1337. For managed hosting, deploy Strapi to Strapi Cloud and point your PHP app's environment variables to that URL.