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/commonmarkConfigure 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.comNever 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.