SEO Metadata
SEO Title Options
- Building a PHP Blog Engine From Scratch: No Framework
- Building a PHP Blog Engine From Scratch: Practical 2026
- Core PHP Playbook: Building a PHP Blog Engine From Scratch
Meta Description Options
- Learn Building a PHP Blog Engine From Scratch: No Framework, Clean Code with a practical Core PHP framework, expert mistakes, implementation steps, examples.
- Builds a feature-complete blog - routing, templating, SQLite storage, markdown support - using only the PHP standard library.
URL Slug
building-php-blog-engine-from-scratch-no-framework-clean-code
Focus Keyword
Building a PHP Blog Engine From Scratch: No Framework, Clean Code
Additional LSI Keywords
- Core PHP
- PHP
- Blog Engine
- SQLite
- Markdown
- No Framework
- Building a PHP Blog Engine From Scratch: No Framework, Clean Code
- production checklist
- implementation guide
- best practices
- architecture decisions
- testing strategy
Table of Contents
- Article overview
- What Building a PHP Blog Engine From Scratch: No Framework, Clean Code means
- Why it matters now
- Implementation framework
- Practical comparison
- Expert workflow
- Common mistakes
- Media and link plan
- Original technical deep dive
- FAQ
- Structured data
- Conclusion
Article overview
Building a PHP Blog Engine From Scratch: No Framework, Clean Code is the kind of topic that looks simple until it reaches production. Teams usually discover the real cost late: unclear boundaries, weak defaults, hidden maintenance work, and decisions that seemed harmless when the codebase was small.
The problem gets worse when the article, tutorial, or implementation guide only explains the happy path. This guide closes that gap with a practical framework, a comparison table, common mistakes, and a deep technical section you can use while planning real work.
Keep reading for the non-obvious part: the safest implementation is rarely the most impressive-looking one. It is the one your team can debug, test, document, and evolve without turning every future change into archaeology.
Key Takeaways
- Building a PHP Blog Engine From Scratch: No Framework, Clean Code should be evaluated as a production decision, not only as a syntax or tooling choice.
- The best implementation keeps responsibilities visible, with clear ownership, tests, documentation, and rollback paths.
- Search visibility improves when practical depth, structured answers, and expert examples live on the same page.
[IMAGE: A mobile-first technical article layout showing the main concept, decision table, implementation checklist, and FAQ blocks. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code expert guide for Core PHP]
What Building a PHP Blog Engine From Scratch: No Framework, Clean Code means
Building a PHP Blog Engine From Scratch: No Framework, Clean Code means applying core php knowledge to a concrete engineering decision, then turning that decision into reliable code, documentation, and operational behavior. In practice, it combines the topic's core concepts with trade-off analysis, implementation boundaries, testing strategy, and maintenance discipline.
This is the definition worth optimizing for featured snippets because it avoids hype. It tells the reader what the topic does and what a professional implementation must include.
Why it matters now
The technical web is more crowded than it was a few years ago. Thin tutorials can still get indexed, but they rarely earn trust from senior developers, buyers, AI answer systems, or teams that need production guidance.
For core php topics, the strongest content now has three layers:
- a clear answer for fast scanning
- a practical framework for implementation
- expert context that explains what breaks later
That same structure helps search engines understand the page. It also helps readers decide whether the advice fits their project.
Implementation framework
Use this framework before adopting the approach described in this article.
- Define the user problem and the production risk.
- Identify the smallest reliable implementation boundary.
- Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
- Add tests for the behavior that would hurt if it regressed.
- Document the trade-off, not only the final code.
- Measure the result with logs, metrics, or user-facing outcomes.
- Revisit the decision after real usage exposes edge cases.
The sequence is deliberately conservative. It keeps the work grounded in outcomes instead of novelty.
[IMAGE: A seven-step implementation framework with discovery, boundary design, configuration, tests, documentation, measurement, and iteration. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code implementation framework]
Practical comparison
| Decision area | Strong approach | Weak approach | Why it matters |
|---|---|---|---|
| Scope | Solve one clear problem | Mix unrelated concerns | Focus improves testing and search intent |
| Architecture | Put logic in explicit classes or documented boundaries | Hide behavior in templates or incidental callbacks | Future changes stay easier to review |
| Data flow | Pass prepared data into the view or endpoint | Query or compute in presentation code | Reduces regressions and performance surprises |
| Testing | Cover the risky behavior directly | Test only the happy path | Catches production failures earlier |
| Documentation | Explain trade-offs and limits | Repeat generic definitions | Builds E-E-A-T and reader trust |
| Operations | Track logs, metrics, and rollback steps | Ship without measurement | Makes the decision reversible |
This table is intentionally practical. It gives a reviewer something to check before the implementation becomes expensive to change.
Expert workflow
Expert tip: "Treat Building a PHP Blog Engine From Scratch: No Framework, Clean Code as a system boundary. If the next developer cannot find where the decision lives, how it is tested, and when it should be avoided, the implementation is not finished."
A useful workflow is simple:
- Start with the smallest working example.
- Add the constraints that exist in your real project.
- Remove anything that only demonstrates cleverness.
- Write down the failure modes.
- Add links to related decisions so future readers can navigate the topic cluster.
That last point matters for both humans and search systems. A single article can answer a question; a cluster proves authority.
Common mistakes
Mistake 1: Copying a pattern without its context
A pattern that works in a small demo can fail in a real application. The missing context is usually data volume, team experience, deployment process, security requirements, or observability.
Before copying the pattern, ask what assumption made it safe in the original example.
Mistake 2: Putting business logic in the wrong layer
This is the fastest way to make future debugging expensive. In Laravel, PHP, and server-rendered websites, presentation should receive prepared data, not discover rules on its own.
Keep decision logic in models, actions, services, policies, requests, jobs, or documented helpers where it can be tested directly.
Mistake 3: Optimizing for novelty instead of maintainability
Newer tools and language features can be valuable. They can also hide simple behavior behind unfamiliar syntax.
Use the option that makes the next production incident easier to understand.
Mistake 4: Publishing without a measurement plan
If the article describes a performance, SEO, security, or architecture improvement, define how success will be checked. Logs, tests, crawl diagnostics, analytics, and user behavior are all stronger than assumptions.
[IMAGE: A common-mistakes board with context loss, wrong layer, novelty bias, and missing measurement highlighted. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code common mistakes]
Media and link plan
Image placeholders
- [IMAGE: A concept diagram for Building a PHP Blog Engine From Scratch: No Framework, Clean Code with input, decision boundary, implementation, tests, and production feedback. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code concept diagram]
- [IMAGE: A mobile screenshot-style checklist for Building a PHP Blog Engine From Scratch: No Framework, Clean Code. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code mobile checklist]
- [IMAGE: A comparison table visualization for strong versus weak implementation choices. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code comparison table]
Video placeholder
[VIDEO: Insert a 5-8 minute YouTube walkthrough that demonstrates the main decision, the implementation boundary, the test strategy, and the production caveats for Building a PHP Blog Engine From Scratch: No Framework, Clean Code.]
Trustworthy outbound links
- PHP manual - use this as the trust reference for language-level reference.
- Google Search quality guidance - use this as the trust reference for people-first content and E-E-A-T alignment.
Internal linking opportunities
- Internal guide: Understanding PHP Stream API: File I/O - use this when readers need a related Core PHP follow-up.
- Internal guide: PHP Type System Mastery: Union, Intersection - use this when readers need a related Core PHP follow-up.
Original Technical Deep Dive
A small PHP blog does not need Laravel, Symfony, Slim, Twig, Doctrine, or a Composer dependency tree.
It does need boundaries.
This guide builds a blog engine with:
- one public front controller
- a tiny router
- PHP templates
- SQLite storage through PDO
- prepared statements
- a repository class
- a safe small Markdown renderer
- public post pages
- an RSS feed
- simple admin create, edit, and delete routes
The goal is not to clone a framework. The goal is to keep the moving parts visible and testable.
This guide was reviewed on May 7, 2026 against the PHP manual, SQLite documentation, and the CommonMark specification.
What the blog will support
Routes:
| Method | Path | Purpose |
|---|---|---|
| GET | / | Published post index |
| GET | /posts/{slug} | Published post detail |
| GET | /feed.xml | RSS feed |
| GET | /admin/posts/new | New post form |
| POST | /admin/posts | Create post |
| GET | /admin/posts/{id}/edit | Edit post form |
| POST | /admin/posts/{id} | Update post |
| POST | /admin/posts/{id}/delete | Delete post |
What is intentionally left out:
- comments
- tags pages
- image uploads
- rich-text editor
- full CommonMark compatibility
- user accounts
Those features can be added later. Start with the smallest blog that has real persistence, real rendering, and real HTTP behavior.
Project structure
Use a public document root. Do not expose src/, storage/, or views/ to the web server.
blog-engine/
config/
app.php
public/
index.php
router.php
assets/
app.css
src/
Blog/
AdminPostController.php
BlogController.php
Markdown.php
Post.php
PostRepository.php
Slugger.php
Database/
Connection.php
Migrator.php
Http/
Request.php
Response.php
Router.php
Support/
Html.php
View/
View.php
storage/
database.sqlite
views/
layout.php
posts/
index.php
show.php
admin/
form.php
The public/ directory is the only web-facing directory.
Run it locally
PHP's built-in server is useful for development, not production. It can use a router script. If that script returns false, PHP serves the requested static file as-is.
Create public/router.php:
declare(strict_types=1);
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
$file = __DIR__ . ($path ?: '/');
if ($path !== '/' && is_file($file)) {
return false;
}
require __DIR__ . '/index.php';
Start the app:
php -S 127.0.0.1:8080 -t public public/router.php
In production, use Nginx or Apache with public/ as the document root and route missing files to public/index.php.
Configuration
Keep paths and secrets out of controllers.
declare(strict_types=1);
return [
'name' => 'Acme Blog',
'base_url' => 'https://example.com',
'database_path' => dirname(__DIR__) . '/storage/database.sqlite',
'admin_password_hash' => getenv('BLOG_ADMIN_PASSWORD_HASH') ?: '',
];
The password hash should be generated with password_hash() and stored in the environment, not committed to Git.
Autoload without Composer
Composer is usually the right autoloader. This article avoids dependencies, so use a small PSR-4-style autoloader.
Create public/index.php:
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'App\\';
if (! str_starts_with($class, $prefix)) {
return;
}
$relative = substr($class, strlen($prefix));
$path = dirname(__DIR__) . '/src/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require $path;
}
});
$config = require dirname(__DIR__) . '/config/app.php';
require dirname(__DIR__) . '/src/bootstrap.php';
The autoloader maps App\Http\Router to src/Http/Router.php.
Request object
Wrap superglobals at the edge. After this point, controllers depend on a typed object, not directly on $_SERVER, $_POST, or $_GET.
declare(strict_types=1);
namespace App\Http;
final readonly class Request
{
/**
* @param array<string, string> $query
* @param array<string, string> $post
*/
public function __construct(
public string $method,
public string $path,
public array $query,
public array $post,
public string $basicPassword,
) {}
public static function fromGlobals(): self
{
$uri = $_SERVER['REQUEST_URI'] ?? '/';
$path = parse_url($uri, PHP_URL_PATH);
return new self(
strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET'),
$path ?: '/',
self::stringMap($_GET),
self::stringMap($_POST),
(string) ($_SERVER['PHP_AUTH_PW'] ?? ''),
);
}
public function input(string $key, string $default = ''): string
{
return trim($this->post[$key] ?? $default);
}
/**
* @param array<string, mixed> $values
* @return array<string, string>
*/
private static function stringMap(array $values): array
{
$result = [];
foreach ($values as $key => $value) {
if (is_string($key) && is_scalar($value)) {
$result[$key] = (string) $value;
}
}
return $result;
}
}
[IMAGE: Supporting visual 1 for Building a PHP Blog Engine From Scratch: No Framework, Clean Code, showing Building a PHP Blog Engine From Scratch: No Framework, Clean Code decisions, examples, and PHP, Blog Engine, SQLite. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code building-php-blog-engine-from-scratch-no-framework-clean-code visual 1]
[IMAGE: Supporting visual 1 for Building a PHP Blog Engine From Scratch: No Framework, Clean Code, showing Building a PHP Blog Engine From Scratch: No Framework, Clean Code decisions, examples, and PHP, Blog Engine, SQLite. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code building-php-blog-engine-from-scratch-no-framework-clean-code visual 1]
This keeps request parsing boring. Validation belongs in controllers or form objects, not inside random templates.
Response object
A response should own status, headers, and body.
declare(strict_types=1);
namespace App\Http;
final readonly class Response
{
/**
* @param array<string, string> $headers
*/
public function __construct(
private string $body,
private int $status = 200,
private array $headers = ['Content-Type' => 'text/html; charset=UTF-8'],
) {}
public static function html(string $body, int $status = 200): self
{
return new self($body, $status);
}
public static function xml(string $body): self
{
return new self($body, 200, ['Content-Type' => 'application/rss+xml; charset=UTF-8']);
}
public static function redirect(string $location): self
{
return new self('', 302, ['Location' => $location]);
}
public static function notFound(): self
{
return new self('<h1>Not found</h1>', 404);
}
public function send(): void
{
http_response_code($this->status);
foreach ($this->headers as $name => $value) {
header($name . ': ' . $value);
}
echo $this->body;
}
}
This class is small, but it removes most header handling from controllers.
Router
The router maps HTTP method and path pattern to a callable.
declare(strict_types=1);
namespace App\Http;
use Closure;
final class Router
{
/**
* @var list<array{method: string, path: string, action: Closure}>
*/
private array $routes = [];
public function get(string $path, Closure $action): void
{
$this->add('GET', $path, $action);
}
public function post(string $path, Closure $action): void
{
$this->add('POST', $path, $action);
}
public function dispatch(Request $request): Response
{
foreach ($this->routes as $route) {
if ($route['method'] !== $request->method) {
continue;
}
$params = $this->match($route['path'], $request->path);
if ($params !== null) {
return ($route['action'])($request, $params);
}
}
return Response::notFound();
}
private function add(string $method, string $path, Closure $action): void
{
$this->routes[] = compact('method', 'path', 'action');
}
/**
* @return array<string, string>|null
*/
private function match(string $pattern, string $path): ?array
{
$regex = $this->compile($pattern);
if ($regex === null || preg_match('#^' . $regex . '$#', $path, $matches) !== 1) {
return null;
}
$params = [];
foreach ($matches as $key => $value) {
if (is_string($key)) {
$params[$key] = urldecode($value);
}
}
return $params;
}
private function compile(string $pattern): ?string
{
$parts = preg_split(
'/(\{[a-zA-Z_][a-zA-Z0-9_]*\})/',
$pattern,
-1,
PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY,
);
if ($parts === false) {
return null;
}
$regex = '';
foreach ($parts as $part) {
if (preg_match('/^\{([a-zA-Z_][a-zA-Z0-9_]*)\}$/', $part, $match) === 1) {
$regex .= '(?P<' . $match[1] . '>[^/]+)';
continue;
}
$regex .= preg_quote($part, '#');
}
return $regex;
}
}
This is not a general-purpose router. It is enough for a small blog:
- exact routes
- named path parameters
- GET and POST
- 404 fallback
HTML escaping
Never trust post titles, excerpts, slugs, or form input.
declare(strict_types=1);
namespace App\Support;
final class Html
{
public static function e(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
}
Use this in templates and in the Markdown renderer.
Views with output buffering
PHP is already a template language. Use it carefully.
declare(strict_types=1);
namespace App\View;
use RuntimeException;
use Throwable;
final readonly class View
{
public function __construct(
private string $basePath,
) {}
/**
* @param array<string, mixed> $data
*/
public function render(string $template, array $data = []): string
{
$file = $this->basePath . '/' . $template . '.php';
if (! is_file($file)) {
throw new RuntimeException("Template not found: {$template}");
}
extract($data, EXTR_SKIP);
ob_start();
try {
require $file;
return (string) ob_get_clean();
} catch (Throwable $exception) {
ob_end_clean();
throw $exception;
}
}
}
Output buffering lets a template return a string instead of printing half a response before a controller has finished.
Database connection
PDO with SQLite is enough for a single-instance blog.
declare(strict_types=1);
namespace App\Database;
use PDO;
final class Connection
{
public static function make(string $path): PDO
{
$directory = dirname($path);
if (! is_dir($directory)) {
mkdir($directory, 0775, true);
}
$pdo = new PDO('sqlite:' . $path, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('PRAGMA foreign_keys = ON');
$pdo->exec('PRAGMA journal_mode = WAL');
return $pdo;
}
}
WAL mode helps readers and writers overlap better, but it is not magic. SQLite still has one writer at a time. For a small blog, that is normally fine.
Migration
Use a simple migrator. Do not edit tables manually on production.
declare(strict_types=1);
namespace App\Database;
use PDO;
final readonly class Migrator
{
public function __construct(
private PDO $pdo,
) {}
public function migrate(): void
{
$this->pdo->exec(<<<'SQL'
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
excerpt TEXT NOT NULL DEFAULT '',
body_markdown TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('draft', 'published')),
published_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
SQL);
$this->pdo->exec(<<<'SQL'
CREATE INDEX IF NOT EXISTS idx_posts_public
ON posts (status, published_at DESC)
SQL);
}
}
Use ISO-like datetime strings in UTC:
$now = gmdate('Y-m-d H:i:s');
For a small blog, this is easy to inspect and sort.
Post model
Keep the model immutable.
declare(strict_types=1);
namespace App\Blog;
final readonly class Post
{
public function __construct(
public int $id,
public string $slug,
public string $title,
public string $excerpt,
public string $bodyMarkdown,
public string $status,
public ?string $publishedAt,
public string $createdAt,
public string $updatedAt,
) {}
/**
* @param array<string, mixed> $row
*/
public static function fromRow(array $row): self
{
return new self(
id: (int) $row['id'],
slug: (string) $row['slug'],
title: (string) $row['title'],
excerpt: (string) $row['excerpt'],
bodyMarkdown: (string) $row['body_markdown'],
status: (string) $row['status'],
publishedAt: $row['published_at'] !== null ? (string) $row['published_at'] : null,
createdAt: (string) $row['created_at'],
updatedAt: (string) $row['updated_at'],
);
}
}
Do not pass raw database rows deep into templates.
Slug generation
Slugs must be stable. Changing a title later should not silently break URLs.
declare(strict_types=1);
namespace App\Blog;
final class Slugger
{
public static function slug(string $title): string
{
$slug = strtolower($title);
$slug = preg_replace('/[^a-z0-9]+/i', '-', $slug) ?? '';
$slug = trim($slug, '-');
if ($slug !== '') {
return $slug;
}
return 'post-' . substr(hash('sha256', $title), 0, 12);
}
}
This is intentionally ASCII-only. If the site needs excellent multilingual slugs, add a real transliteration policy instead of hiding it in a regex.
Repository
All SQL goes in one class.
declare(strict_types=1);
namespace App\Blog;
use PDO;
final readonly class PostRepository
{
public function __construct(
private PDO $pdo,
) {}
/**
* @return list<Post>
*/
public function published(int $limit = 20, int $offset = 0): array
{
$statement = $this->pdo->prepare(<<<'SQL'
SELECT *
FROM posts
WHERE status = 'published'
AND published_at IS NOT NULL
AND published_at <= :now
ORDER BY published_at DESC
LIMIT :limit OFFSET :offset
SQL);
$statement->bindValue('now', gmdate('Y-m-d H:i:s'));
$statement->bindValue('limit', $limit, PDO::PARAM_INT);
$statement->bindValue('offset', $offset, PDO::PARAM_INT);
$statement->execute();
return array_map(
static fn (array $row): Post => Post::fromRow($row),
$statement->fetchAll(),
);
}
public function findPublishedBySlug(string $slug): ?Post
{
$statement = $this->pdo->prepare(<<<'SQL'
SELECT *
FROM posts
WHERE slug = :slug
AND status = 'published'
AND published_at IS NOT NULL
AND published_at <= :now
LIMIT 1
SQL);
$statement->execute([
'slug' => $slug,
'now' => gmdate('Y-m-d H:i:s'),
]);
$row = $statement->fetch();
return is_array($row) ? Post::fromRow($row) : null;
}
public function find(int $id): ?Post
{
$statement = $this->pdo->prepare('SELECT * FROM posts WHERE id = :id LIMIT 1');
$statement->bindValue('id', $id, PDO::PARAM_INT);
$statement->execute();
$row = $statement->fetch();
return is_array($row) ? Post::fromRow($row) : null;
}
public function create(string $title, string $excerpt, string $body, string $status): int
{
$now = gmdate('Y-m-d H:i:s');
$slug = $this->uniqueSlug(Slugger::slug($title));
$statement = $this->pdo->prepare(<<<'SQL'
INSERT INTO posts (
slug,
title,
excerpt,
body_markdown,
status,
published_at,
created_at,
updated_at
) VALUES (
:slug,
:title,
:excerpt,
:body_markdown,
:status,
:published_at,
:created_at,
:updated_at
)
SQL);
$statement->execute([
'slug' => $slug,
'title' => $title,
'excerpt' => $excerpt,
'body_markdown' => $body,
'status' => $status,
'published_at' => $status === 'published' ? $now : null,
'created_at' => $now,
'updated_at' => $now,
]);
return (int) $this->pdo->lastInsertId();
}
public function update(int $id, string $title, string $excerpt, string $body, string $status): void
{
$existing = $this->find($id);
if ($existing === null) {
return;
}
$now = gmdate('Y-m-d H:i:s');
$publishedAt = $existing->publishedAt;
if ($status === 'published' && $publishedAt === null) {
$publishedAt = $now;
}
if ($status === 'draft') {
$publishedAt = null;
}
$statement = $this->pdo->prepare(<<<'SQL'
UPDATE posts
SET title = :title,
excerpt = :excerpt,
body_markdown = :body_markdown,
status = :status,
published_at = :published_at,
updated_at = :updated_at
WHERE id = :id
SQL);
$statement->execute([
'id' => $id,
'title' => $title,
'excerpt' => $excerpt,
'body_markdown' => $body,
'status' => $status,
'published_at' => $publishedAt,
'updated_at' => $now,
]);
}
public function delete(int $id): void
{
$statement = $this->pdo->prepare('DELETE FROM posts WHERE id = :id');
$statement->bindValue('id', $id, PDO::PARAM_INT);
$statement->execute();
}
private function uniqueSlug(string $base): string
{
$slug = $base;
$suffix = 2;
while ($this->slugExists($slug)) {
$slug = $base . '-' . $suffix;
$suffix++;
}
return $slug;
}
private function slugExists(string $slug): bool
{
$statement = $this->pdo->prepare('SELECT 1 FROM posts WHERE slug = :slug LIMIT 1');
$statement->execute(['slug' => $slug]);
return $statement->fetchColumn() !== false;
}
}
Prepared statements are not optional here. Titles, bodies, slugs, and excerpts are all user-controlled.
Markdown renderer
Full Markdown is bigger than it looks. CommonMark has many edge cases. If you need complete compatibility, use a maintained parser.
For a standard-library blog, support a deliberately small subset:
- paragraphs
#,##, and###headings- unordered lists
- fenced code blocks
- inline code
- HTTPS links
[IMAGE: Supporting visual 2 for Building a PHP Blog Engine From Scratch: No Framework, Clean Code, showing Building a PHP Blog Engine From Scratch: No Framework, Clean Code decisions, examples, and PHP, Blog Engine, SQLite. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code building-php-blog-engine-from-scratch-no-framework-clean-code visual 2]
Everything else is plain text.
declare(strict_types=1);
namespace App\Blog;
use App\Support\Html;
final class Markdown
{
public function toHtml(string $markdown): string
{
$lines = preg_split('/\R/', $markdown) ?: [];
$html = [];
$paragraph = [];
$list = [];
$code = [];
$inCode = false;
foreach ($lines as $line) {
if (str_starts_with($line, '```')) {
if ($inCode) {
$html[] = '<pre><code>' . Html::e(implode("\n", $code)) . '</code></pre>';
$code = [];
$inCode = false;
} else {
$this->flushParagraph($html, $paragraph);
$this->flushList($html, $list);
$inCode = true;
}
continue;
}
if ($inCode) {
$code[] = $line;
continue;
}
if (trim($line) === '') {
$this->flushParagraph($html, $paragraph);
$this->flushList($html, $list);
continue;
}
if (preg_match('/^(#{1,3})\s+(.+)$/', $line, $match) === 1) {
$this->flushParagraph($html, $paragraph);
$this->flushList($html, $list);
$level = strlen($match[1]);
$html[] = sprintf(
'<h%d>%s</h%d>',
$level,
$this->inline($match[2]),
$level,
);
continue;
}
if (preg_match('/^-\s+(.+)$/', $line, $match) === 1) {
$this->flushParagraph($html, $paragraph);
$list[] = $this->inline($match[1]);
continue;
}
$paragraph[] = $line;
}
if ($inCode) {
$html[] = '<pre><code>' . Html::e(implode("\n", $code)) . '</code></pre>';
}
$this->flushParagraph($html, $paragraph);
$this->flushList($html, $list);
return implode("\n", $html);
}
private function inline(string $text): string
{
$tokens = [];
$text = preg_replace_callback('/`([^`]+)`/', function (array $match) use (&$tokens): string {
$key = '%%BLOG_TOKEN_' . count($tokens) . '%%';
$tokens[$key] = '<code>' . Html::e($match[1]) . '</code>';
return $key;
}, $text) ?? $text;
$text = preg_replace_callback(
'/\[([^\]]{1,120})\]\((https:\/\/[^\s)]+)\)/',
function (array $match) use (&$tokens): string {
$key = '%%BLOG_TOKEN_' . count($tokens) . '%%';
$tokens[$key] = sprintf(
'<a href="%s" rel="noopener noreferrer">%s</a>',
Html::e($match[2]),
Html::e($match[1]),
);
return $key;
},
$text,
) ?? $text;
return strtr(Html::e($text), $tokens);
}
/**
* @param list<string> $html
* @param list<string> $paragraph
*/
private function flushParagraph(array &$html, array &$paragraph): void
{
if ($paragraph === []) {
return;
}
$html[] = '<p>' . $this->inline(implode(' ', $paragraph)) . '</p>';
$paragraph = [];
}
/**
* @param list<string> $html
* @param list<string> $list
*/
private function flushList(array &$html, array &$list): void
{
if ($list === []) {
return;
}
$items = array_map(
static fn (string $item): string => '<li>' . $item . '</li>',
$list,
);
$html[] = "<ul>\n" . implode("\n", $items) . "\n</ul>";
$list = [];
}
}
The important security rule: raw Markdown never becomes raw HTML. Text is escaped first. Only the renderer creates allowed HTML tags.
Blog controller
Public reads are simple.
declare(strict_types=1);
namespace App\Blog;
use App\Http\Response;
use App\Support\Html;
use App\View\View;
final readonly class BlogController
{
public function __construct(
private PostRepository $posts,
private Markdown $markdown,
private View $view,
private string $siteName,
private string $baseUrl,
) {}
public function index(): Response
{
return Response::html($this->view->render('layout', [
'title' => $this->siteName,
'content' => $this->view->render('posts/index', [
'posts' => $this->posts->published(),
]),
]));
}
public function show(string $slug): Response
{
$post = $this->posts->findPublishedBySlug($slug);
if ($post === null) {
return Response::notFound();
}
return Response::html($this->view->render('layout', [
'title' => $post->title,
'content' => $this->view->render('posts/show', [
'post' => $post,
'body' => $this->markdown->toHtml($post->bodyMarkdown),
]),
]));
}
public function feed(): Response
{
$items = array_map(function (Post $post): string {
$url = $this->baseUrl . '/posts/' . rawurlencode($post->slug);
return sprintf(
'<item><title>%s</title><link>%s</link><guid>%s</guid><description>%s</description><pubDate>%s</pubDate></item>',
Html::e($post->title),
Html::e($url),
Html::e($url),
Html::e($post->excerpt),
gmdate(DATE_RSS, strtotime($post->publishedAt ?? $post->createdAt) ?: time()),
);
}, $this->posts->published(20));
$xml = sprintf(
'<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>%s</title><link>%s</link>%s</channel></rss>',
Html::e($this->siteName),
Html::e($this->baseUrl),
implode('', $items),
);
return Response::xml($xml);
}
}
RSS is XML. Escape it. Do not concatenate raw titles into the feed.
Admin controller
This example accepts either HTTP Basic auth or the password field posted by the form. In a real public admin, use sessions, CSRF protection, rate limiting, and an HTTPS-only deployment.
declare(strict_types=1);
namespace App\Blog;
use App\Http\Request;
use App\Http\Response;
use App\View\View;
final readonly class AdminPostController
{
public function __construct(
private PostRepository $posts,
private View $view,
private string $passwordHash,
) {}
public function createForm(Request $request): Response
{
if (! $this->authorized($request)) {
return $this->unauthorized();
}
return Response::html($this->view->render('layout', [
'title' => 'New post',
'content' => $this->view->render('admin/form', [
'action' => '/admin/posts',
'post' => null,
]),
]));
}
public function store(Request $request): Response
{
if (! $this->authorized($request)) {
return $this->unauthorized();
}
$id = $this->posts->create(
title: $request->input('title'),
excerpt: $request->input('excerpt'),
body: $request->input('body_markdown'),
status: $this->status($request),
);
return Response::redirect('/admin/posts/' . $id . '/edit');
}
public function editForm(Request $request, int $id): Response
{
if (! $this->authorized($request)) {
return $this->unauthorized();
}
$post = $this->posts->find($id);
if ($post === null) {
return Response::notFound();
}
return Response::html($this->view->render('layout', [
'title' => 'Edit post',
'content' => $this->view->render('admin/form', [
'action' => '/admin/posts/' . $id,
'post' => $post,
]),
]));
}
public function update(Request $request, int $id): Response
{
if (! $this->authorized($request)) {
return $this->unauthorized();
}
$this->posts->update(
id: $id,
title: $request->input('title'),
excerpt: $request->input('excerpt'),
body: $request->input('body_markdown'),
status: $this->status($request),
);
return Response::redirect('/admin/posts/' . $id . '/edit');
}
public function delete(Request $request, int $id): Response
{
if (! $this->authorized($request)) {
return $this->unauthorized();
}
$this->posts->delete($id);
return Response::redirect('/');
}
private function authorized(Request $request): bool
{
$password = $request->basicPassword !== ''
? $request->basicPassword
: $request->input('admin_password');
return $this->passwordHash !== ''
&& $password !== ''
&& password_verify($password, $this->passwordHash);
}
private function unauthorized(): Response
{
return new Response('<h1>Unauthorized</h1>', 401, [
'Content-Type' => 'text/html; charset=UTF-8',
'WWW-Authenticate' => 'Basic realm="Blog admin"',
]);
}
private function status(Request $request): string
{
return $request->input('status') === 'published' ? 'published' : 'draft';
}
}
[IMAGE: Supporting visual 2 for Building a PHP Blog Engine From Scratch: No Framework, Clean Code, showing Building a PHP Blog Engine From Scratch: No Framework, Clean Code decisions, examples, and PHP, Blog Engine, SQLite. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code building-php-blog-engine-from-scratch-no-framework-clean-code visual 2]
This password field is deliberately simple for the article. It avoids pretending that a production admin is solved by one snippet.
Templates
Layout:
use App\Support\Html;
/** @var string $title */
/** @var string $content */
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= Html::e($title) </title>
<link rel="alternate" type="application/rss+xml" href="/feed.xml">
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<header>
<a href="/">Acme Blog</a>
</header>
<main>
<?= $content </main>
</body>
</html>
Post index:
use App\Blog\Post;
use App\Support\Html;
/** @var list<Post> $posts */
<h1>Posts</h1>
foreach ($posts as $post): <article>
<h2>
<a href="/posts/<?= rawurlencode($post->slug) ">
<?= Html::e($post->title) </a>
</h2>
<p><?= Html::e($post->excerpt) </p>
<time datetime="<?= Html::e($post->publishedAt ?? '') ">
<?= Html::e(substr($post->publishedAt ?? '', 0, 10)) </time>
</article>
endforeach;
Post detail:
use App\Blog\Post;
use App\Support\Html;
/** @var Post $post */
/** @var string $body */
<article>
<h1><?= Html::e($post->title) </h1>
<p><?= Html::e($post->excerpt) </p>
<div class="post-body">
<?= $body </div>
</article>
Admin form:
use App\Blog\Post;
use App\Support\Html;
/** @var string $action */
/** @var Post|null $post */
<form method="post" action="<?= Html::e($action) ">
<label>
Admin password
<input type="password" name="admin_password" required>
</label>
<label>
Title
<input name="title" value="<?= Html::e($post?->title ?? '') " required>
</label>
<label>
Excerpt
<textarea name="excerpt" required><?= Html::e($post?->excerpt ?? '') </textarea>
</label>
<label>
Body
<textarea name="body_markdown" rows="20" required><?= Html::e($post?->bodyMarkdown ?? '') </textarea>
</label>
<label>
Status
<select name="status">
<option value="draft" <?= $post?->status !== 'published' ? 'selected' : '' >Draft</option>
<option value="published" <?= $post?->status === 'published' ? 'selected' : '' >Published</option>
</select>
</label>
<button type="submit">Save</button>
</form>
if ($post !== null): <form method="post" action="/admin/posts/<?= $post->id /delete">
<input type="password" name="admin_password" required>
<button type="submit">Delete</button>
</form>
endif;
Only $body in the post detail is intentionally unescaped because it comes from the Markdown renderer, which already escapes text and emits a small allowlist of tags.
Wire the application
Create src/bootstrap.php:
declare(strict_types=1);
use App\Blog\AdminPostController;
use App\Blog\BlogController;
use App\Blog\Markdown;
use App\Blog\PostRepository;
use App\Database\Connection;
use App\Database\Migrator;
use App\Http\Request;
use App\Http\Router;
use App\View\View;
/** @var array{name: string, base_url: string, database_path: string, admin_password_hash: string} $config */
$pdo = Connection::make($config['database_path']);
(new Migrator($pdo))->migrate();
$posts = new PostRepository($pdo);
$view = new View(dirname(__DIR__) . '/views');
$markdown = new Markdown();
$blog = new BlogController(
posts: $posts,
markdown: $markdown,
view: $view,
siteName: $config['name'],
baseUrl: $config['base_url'],
);
$admin = new AdminPostController(
posts: $posts,
view: $view,
passwordHash: $config['admin_password_hash'],
);
$router = new Router();
$router->get('/', static fn () => $blog->index());
$router->get('/feed.xml', static fn () => $blog->feed());
$router->get('/posts/{slug}', static fn (Request $request, array $params) => $blog->show($params['slug']));
$router->get('/admin/posts/new', static fn (Request $request) => $admin->createForm($request));
$router->post('/admin/posts', static fn (Request $request) => $admin->store($request));
$router->get('/admin/posts/{id}/edit', static fn (Request $request, array $params) => $admin->editForm($request, (int) $params['id']));
$router->post('/admin/posts/{id}', static fn (Request $request, array $params) => $admin->update($request, (int) $params['id']));
$router->post('/admin/posts/{id}/delete', static fn (Request $request, array $params) => $admin->delete($request, (int) $params['id']));
$router->dispatch(Request::fromGlobals())->send();
This is the whole application graph. The front controller loads config. Bootstrap creates objects. Controllers call repositories and views.
Add a first admin password
Generate the hash locally:
php -r 'echo password_hash("change-me", PASSWORD_DEFAULT), PHP_EOL;'
Set it in your environment:
export BLOG_ADMIN_PASSWORD_HASH='$2y$10$...'
Restart the server. The form password is checked with password_verify().
Add basic CSS
Keep styling out of PHP.
body {
margin: 0 auto;
max-width: 72ch;
padding: 2rem;
font-family: system-ui, sans-serif;
line-height: 1.65;
}
a {
color: #0645ad;
}
textarea,
input,
select {
box-sizing: border-box;
display: block;
margin: 0.35rem 0 1rem;
width: 100%;
}
pre {
overflow-x: auto;
padding: 1rem;
background: #f5f5f5;
}
The engine is not coupled to the CSS. Replace it freely.
Validation rules
Before saving a post, enforce minimum rules:
private function validate(Request $request): array
{
$errors = [];
if ($request->input('title') === '') {
$errors[] = 'Title is required.';
}
if (strlen($request->input('title')) > 180) {
$errors[] = 'Title must be 180 characters or less.';
}
if ($request->input('body_markdown') === '') {
$errors[] = 'Body is required.';
}
return $errors;
}
Do not rely only on HTML required attributes. Browsers are not your validation boundary.
What to test first
Start with the parts that are easy to break:
Routermatches/posts/{slug}.Markdownescapes raw HTML.Markdownrenders code fences without executing anything.Sluggerhandles repeated titles.PostRepositorynever returns drafts from public methods.- RSS output escapes title and excerpt text.
- Admin routes reject missing password.
The point of writing these classes small is that most of them can be tested without a web server.
Deployment checklist
Before putting this online:
- Point the web server document root to
public/. - Deny direct web access to
storage/. - Use HTTPS.
- Store the admin password hash in the environment.
- Add CSRF tokens to admin POST routes.
- Add request size limits.
- Back up
storage/database.sqlite. - Keep the SQLite file and its directory writable by the PHP process.
- Run
PRAGMA integrity_checkin a maintenance script. - Add cache headers for CSS and images.
- Generate canonical URLs and metadata for posts.
[IMAGE: Supporting visual 3 for Building a PHP Blog Engine From Scratch: No Framework, Clean Code, showing Building a PHP Blog Engine From Scratch: No Framework, Clean Code decisions, examples, and PHP, Blog Engine, SQLite. Alt: Building a PHP Blog Engine From Scratch: No Framework, Clean Code building-php-blog-engine-from-scratch-no-framework-clean-code visual 3]
The code above is clean enough to extend, but production hardening still matters.
When to stop building from scratch
Move to a framework or mature packages when you need:
- multiple authors
- complex permissions
- file uploads
- revision history
- scheduled publishing
- search
- comments
- full Markdown or HTML sanitization
- queues
- admin sessions
- plugin systems
The clean-code win is not avoiding frameworks forever. The win is knowing where the seams are before the project grows.
FAQ
What is Building a PHP Blog Engine From Scratch: No Framework, Clean Code?
Building a PHP Blog Engine From Scratch: No Framework, Clean Code is a practical core php topic that should be evaluated through implementation scope, production risk, testing, documentation, and long-term maintainability.
When should a team use Building a PHP Blog Engine From Scratch: No Framework, Clean Code?
Use Building a PHP Blog Engine From Scratch: No Framework, Clean Code when it solves a real project constraint, improves clarity, or reduces operational risk. Avoid it when it only adds novelty or hides behavior from future maintainers.
What is the biggest risk with Building a PHP Blog Engine From Scratch: No Framework, Clean Code?
The biggest risk is copying a pattern without its context. Production systems need clear boundaries, rollback options, tests, and observability before a technique becomes dependable.
How do you test Building a PHP Blog Engine From Scratch: No Framework, Clean Code?
Test the smallest unit that owns the behavior, then add integration coverage for the path users or systems actually rely on. Include failure cases, configuration differences, and regression checks.
How does Building a PHP Blog Engine From Scratch: No Framework, Clean Code affect SEO and AI search visibility?
It improves visibility when the article gives a direct answer, expert context, structured headings, internal links, trustworthy references, and FAQ content that matches the visible page.
Conclusion
Building a PHP Blog Engine From Scratch: No Framework, Clean Code is worth doing when the implementation improves clarity, reliability, or delivery speed. It is not worth doing when it hides ownership, increases operational risk, or makes the system harder to explain.
Use the framework above as a review checklist. Then connect this topic to the rest of the project documentation so readers can move from concept to implementation without losing context.