PHP

PHP is a versatile backend language that can consume Strapi APIs, manage structured content, and serve dynamic web applications across frameworks

PHP

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.

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=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.
  • Draft and publish workflows via the same endpoint. Query ?status=published for production pages and ?status=draft for 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:

ToolVersionNotes
PHP8.5+Current stable branch
ComposerLatestPHP dependency manager
Node.js20.x, 22.x, or 24.x (LTS versions)Required for the Strapi server
Guzzle7.10+PHP HTTP client
Strapi5.40.0Latest stable release

Your composer.json should include:

{
  "require": {
    "php": "^8.4",
    "guzzlehttp/guzzle": "^7.10"
  }
}

Install dependencies:

composer require guzzlehttp/guzzle

And 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-project

Once installed, start the development server:

cd my-strapi-project
npm run develop

Open http://localhost:1337/admin and register your first admin account. Then set up your content:

  1. Create content types via the Content-Type Builder in the Admin Panel
  2. Add entries through the Content Manager and publish them — unpublished content won't appear in API responses
  3. Set public permissions: navigate to Settings → Users & Permissions → Public role and enable find and findOne for the content types you need
  4. 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 updates

Step 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 object

Step 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:

FieldTypeNotes
titleTextRequired
slugUIDBased on title
contentRich Text (Blocks)Full body
excerptTextMax 200 characters
coverImageMediaSingle image
authorRelationMany-to-one → User
publishedAtDatetimeAuto-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.json

The 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-here

Front 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.

Frequently Asked Questions

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.