PHP is a versatile backend language that can consume Strapi APIs, manage structured content, and serve dynamic web applications across frameworks
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.
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:
?locale=fr to any REST request and get locale-specific content back. Strapi v5 includes internationalization in core — no extra plugins or PHP translation layers needed.?status=published for production pages and ?status=draft for preview modes using appropriately scoped tokens. No separate staging infrastructure required.This section covers everything from environment setup through full CRUD operations, error handling, and authentication.
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"
}
}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:
find and findOne for the content types you needStrapi 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.
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.
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.
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'] ?? [];
}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 updatesUpdates 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 objectGuzzle'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.
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.
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.
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.
php-blog/
├── public/
│ └── index.php
├── src/
│ └── StrapiClient.php
├── templates/
│ ├── layout.php
│ ├── blog-list.php
│ └── blog-post.php
├── .env
└── composer.jsonThis 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-hereThe 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.
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>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>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.
PHP (Hypertext Preprocessor) is a server-side scripting language widely used for web development. It allows you to handle server-side tasks like form submissions, database interactions, and user authentication. When integrated with Strapi, PHP can consume Strapi's API to manage dynamic content, while Strapi handles content management, media storage, and API generation.
Integrating PHP with Strapi brings a clean separation between content management and business logic. Strapi handles content creation, management, and API delivery, while PHP processes server-side logic, authentication, and complex workflows. This division improves flexibility, scalability, and maintainability by allowing each platform to handle its strengths without overlap.
To integrate PHP with Strapi, ensure that you have PHP installed on your server, as well as a working Strapi project (v4 or later). You'll need a basic understanding of PHP and Strapi, along with REST API concepts. You should also have an API token in Strapi for secure communication between the two platforms.
Strapi automatically generates RESTful API endpoints for all content types. In PHP, you can create a client to interact with these APIs, sending HTTP requests and processing the responses. For secure integration, use JWT for authentication, and ensure that API tokens are stored securely in environment variables.
For secure communication, use JWT (JSON Web Tokens) for authentication. Strapi generates JWTs for users, which you can then use in PHP to access protected endpoints. Ensure you configure CORS properly in Strapi to allow PHP-based requests, and store sensitive tokens and API keys securely using environment variables.
Challenges may include managing authentication tokens between the platforms, ensuring proper data mapping from Strapi to PHP, and optimizing API call performance. Solutions involve using Strapi's role-based access control for managing API permissions, utilizing GraphQL or REST APIs for efficient data fetching, and leveraging caching for performance optimization.
You can explore the official documentation for both Strapi and PHP for in-depth resources. Additionally, participate in community forums, Strapi's Open Office Hours, or check GitHub repositories for integration examples. There are also various tutorials and guides available online to help troubleshoot any issues you may encounter during the integration.