Back to blog

Core PHP

Building a PHP Blog Engine From Scratch: No Framework, Clean Code

Builds a feature-complete blog - routing, templating, SQLite storage, markdown support - using only the PHP standard library.

  • PHP
  • Blog Engine
  • SQLite
  • Markdown
  • No Framework

SEO Metadata

SEO Title Options

  1. Building a PHP Blog Engine From Scratch: No Framework
  2. Building a PHP Blog Engine From Scratch: Practical 2026
  3. Core PHP Playbook: Building a PHP Blog Engine From Scratch

Meta Description Options

  1. Learn Building a PHP Blog Engine From Scratch: No Framework, Clean Code with a practical Core PHP framework, expert mistakes, implementation steps, examples.
  2. 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

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.

  1. Define the user problem and the production risk.
  2. Identify the smallest reliable implementation boundary.
  3. Keep configuration, secrets, and environment-specific behavior outside the article's core logic.
  4. Add tests for the behavior that would hurt if it regressed.
  5. Document the trade-off, not only the final code.
  6. Measure the result with logs, metrics, or user-facing outcomes.
  7. 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 areaStrong approachWeak approachWhy it matters
ScopeSolve one clear problemMix unrelated concernsFocus improves testing and search intent
ArchitecturePut logic in explicit classes or documented boundariesHide behavior in templates or incidental callbacksFuture changes stay easier to review
Data flowPass prepared data into the view or endpointQuery or compute in presentation codeReduces regressions and performance surprises
TestingCover the risky behavior directlyTest only the happy pathCatches production failures earlier
DocumentationExplain trade-offs and limitsRepeat generic definitionsBuilds E-E-A-T and reader trust
OperationsTrack logs, metrics, and rollback stepsShip without measurementMakes 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]

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

Internal linking opportunities

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:

MethodPathPurpose
GET/Published post index
GET/posts/{slug}Published post detail
GET/feed.xmlRSS feed
GET/admin/posts/newNew post form
POST/admin/postsCreate post
GET/admin/posts/{id}/editEdit post form
POST/admin/posts/{id}Update post
POST/admin/posts/{id}/deleteDelete 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:

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

<?php

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:

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

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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.

<?php

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:

<?php

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:

<?php

use App\Blog\Post;
use App\Support\Html;

/** @var list<Post> $posts */
?>
<h1>Posts</h1>

<?php 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>
<?php endforeach; ?>

Post detail:

<?php

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:

<?php

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>

<?php 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>
<?php 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:

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

  • Router matches /posts/{slug}.
  • Markdown escapes raw HTML.
  • Markdown renders code fences without executing anything.
  • Slugger handles repeated titles.
  • PostRepository never 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_check in 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.

Top