$ cli-blog docs
Examples & Guides

Add a blog to PHP

Build a secure PHP blog with published reads, Markdown rendering, draft creation, pagination, caching, sitemap, and RSS.

You can add Cli Blog to a plain PHP application without adopting a full framework. This guide uses Guzzle for HTTP and CommonMark for safe server-rendered Markdown.

Architecture

The browser talks to your PHP application. PHP calls Cli Blog, renders published content, and controls caching. Only an authenticated server route can use the private key to create a draft.

Prerequisites

You need PHP 8.1 or newer, Composer, the JSON extension, and a Cli Blog organization with public and private API keys.

Install dependencies

composer require guzzlehttp/guzzle league/commonmark

Configure API keys

Set these values in your host's environment, not a web-readable file:

CLI_BLOG_PUBLIC_KEY=<public-api-key>
CLI_BLOG_API_KEY=<private-api-key>
CLI_BLOG_BASE_URL=https://api.cli-blog.com

Never send the private value to JavaScript, render it into HTML, or include it in an exception page. Exclude local environment files from version control.

Create an API helper

<?php

use GuzzleHttp\Client;

function cliBlogClient(): Client
{
    return new Client([
        'base_uri' => getenv('CLI_BLOG_BASE_URL') ?: 'https://api.cli-blog.com',
        'timeout' => 10,
        'http_errors' => false,
    ]);
}

function cliBlogJson(string $method, string $path, string $key, array $options = []): array
{
    $client = cliBlogClient();
    $options['headers']['x-api-key'] = $key;
    $options['headers']['accept'] = 'application/json';
    $response = $client->request($method, $path, $options);
    $payload = json_decode((string) $response->getBody(), true);

    if ($response->getStatusCode() >= 400) {
        throw new RuntimeException('Cli Blog request failed', $response->getStatusCode());
    }

    return $payload;
}

Keep the status code available to your router so it can distinguish missing posts, authentication failures, and transient upstream failures.

List published posts

$result = cliBlogJson('GET', '/v1/posts', getenv('CLI_BLOG_PUBLIC_KEY'), [
    'query' => array_filter([
        'status' => 'published',
        'locale' => 'en-US',
        'fields' => 'summary,seo',
        'include' => 'authors,tags',
        'limit' => 20,
        'after' => $_GET['after'] ?? null,
    ]),
]);

summary and seo provide card and metadata fields. Includes embed author and tag records, which avoids extra round trips.

Render the list with escaped output:

<?php foreach ($result['data'] as $post): ?>
  <article>
    <h2>
      <a href="/blog/<?= rawurlencode($post['slug']) ?>">
        <?= htmlspecialchars($post['title'], ENT_QUOTES, 'UTF-8') ?>
      </a>
    </h2>
    <p><?= htmlspecialchars($post['excerpt'] ?? '', ENT_QUOTES, 'UTF-8') ?></p>
  </article>
<?php endforeach; ?>

Add cursor pagination

When has_more is true, put next_cursor in the next link's after query parameter. Use rawurlencode and do not inspect or modify the cursor. Every next request must preserve the same locale, filters, and sort.

The default limit is 20 and the maximum is 100. Cursor mode is the recommended path for load-more pages and background traversal because it avoids exact total counts. Numbered pagination is a slower opt-in for interfaces that truly require exact pages.

Retrieve a post by slug

$slug = rawurlencode($routeParams['slug']);
$post = cliBlogJson('GET', "/v1/posts/{$slug}", getenv('CLI_BLOG_PUBLIC_KEY'), [
    'query' => [
        'locale' => 'en-US',
        'fields' => 'summary,content,seo',
        'include' => 'authors,categories,tags,media',
    ],
]);

List routes should stay lean; request content only when rendering an article. Slug lookup is locale-aware.

Render Markdown safely

Configure CommonMark to strip embedded HTML and reject unsafe links:

use League\CommonMark\GithubFlavoredMarkdownConverter;

$converter = new GithubFlavoredMarkdownConverter([
    'html_input' => 'strip',
    'allow_unsafe_links' => false,
]);

$articleHtml = $converter->convert($post['body_markdown']);

Render $articleHtml as the one pre-sanitized HTML value. Continue passing the title, excerpt, bylines, alt text, and SEO fields through htmlspecialchars.

Add SEO metadata

<?php
$title = $post['seo_title'] ?? $post['title'];
$description = $post['seo_description'] ?? $post['excerpt'] ?? '';
?>
<title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8') ?></title>
<meta name="description" content="<?= htmlspecialchars($description, ENT_QUOTES, 'UTF-8') ?>">

If canonical_url exists, emit an escaped canonical link. The seo field group also contains robots and social overrides; apply them in the same template layer.

Create a trusted draft endpoint

Run your authentication and authorization checks before this code:

$input = json_decode(file_get_contents('php://input'), true);
$title = trim((string) ($input['title'] ?? ''));
$markdown = (string) ($input['markdown'] ?? '');

if ($title === '' || $markdown === '') {
    http_response_code(422);
    exit;
}

$draft = cliBlogJson('POST', '/v1/posts', getenv('CLI_BLOG_API_KEY'), [
    'json' => [
        'title' => $title,
        'body_markdown' => $markdown,
        'locale' => 'en-US',
        'status' => 'draft',
    ],
]);

Return only safe fields such as the draft ID and status. Do not let the request choose a published status. Publishing should be a separate action with approval and CSRF protection.

Serve sitemap and feed XML

Use the public key to request GET https://api.cli-blog.com/v1/sitemap?locale=en-US&limit=100 and forward the body with Content-Type: application/xml; charset=utf-8. Do the same for GET https://api.cli-blog.com/v1/feed?locale=en-US&limit=20 with application/rss+xml; charset=utf-8.

Cache both XML responses and serve them on your public domain. Link /feed.xml from the document head so readers and feed clients can discover it.

Cache and invalidate

Cache published list and detail responses in your application's cache or reverse proxy. Use a short freshness period plus stale serving where supported. Purge the list, affected slug, sitemap, and feed entries after your trusted publish workflow. Never put draft responses in a shared cache.

Handle errors

Return a local 404 page for an upstream 404. Treat 401 as invalid credentials, 403 as the wrong key type or permissions, and 429 as a signal to retry later. Retry only transient 429 and 5xx failures with a small bounded backoff.

Log the method, path, status, and a safe correlation value. Do not log the x-api-key header or complete environment contents.

Production checklist

  • Keep keys in the process environment or a secret manager.
  • Authenticate, authorize, validate, rate-limit, and CSRF-protect write routes.
  • Escape plain text and strip raw HTML from rendered Markdown.
  • Cache published reads and invalidate all dependent pages after publishing.
  • Preserve cursor query parameters between requests.
  • Set canonical, sitemap, feed, robots, empty, and 404 behavior.
  • Test timeouts and rate limits without exposing upstream error details.

On this page