Skip to main content
PHP

Building Modern PHP Applications Without Frameworks

Build clean PHP projects utilizing composer autoloading, a router, and dependency injection from scratch.

G

gs_admin

Author & Reviewer

Published

Mar 27, 2026

Read Time

15 min read

index.php
🐘
PHP

# Building Modern PHP Applications Without Frameworks: A Architectural Guide

SEO Meta Description

Learn how to build a modern PHP application from scratch without heavy frameworks. A complete technical guide detailing routing, dependency injection, MVC patterns, PSR-4 autoloading, and middleware architecture.

---

Introduction

Frameworks like Laravel and Symfony have changed the PHP landscape for the better, providing developers with solid tools to build applications quickly. However, relying on frameworks can abstract too much of the core language, leading to developers who don't understand the underlying request-response cycle, autoloading standards, or HTTP lifecycle.

Building a custom application structure from scratch is a valuable learning exercise. It helps you understand routing, controllers, dependency injection containers, and middleware.

In this architectural guide, we will build a modular, secure, and production-ready PHP application without using any full-stack framework. We will implement modern patterns like PSR-4 autoloading, custom routing with controller dispatching, a Dependency Injection container, and a middleware request-handling pipeline.

---

Table of Contents

  1. 1. The Philosophy of Frameworkless PHP
  1. 2. Project Layout and Directory Architecture
  1. 3. Composer, PSR-4 Autoloading, and Environments
  1. 4. The Front Controller & Entry Point
  1. 5. Building a Router and Dispatcher
  1. 6. Implementing a Dependency Injection (DI) Container
  1. 7. The Controller Layer and MVC Design
  1. 8. Pipeline Middleware Architecture
  1. 9. Integrating the Database Layer
  1. 10. Common Mistakes and Pitfalls
  1. 11. Performance and Production Tuning
  1. 12. Frequently Asked Questions (FAQs)
  1. 13. Key Takeaways
  1. 14. Related Resources

---

The Philosophy of Frameworkless PHP

When we build an application without a framework, we are not suggesting you rewrite PDO database drivers, template engines, or password hashing libraries from scratch. That is a security and performance risk.

Instead, the frameworkless philosophy is to use small, single-purpose libraries and bind them together using standard design patterns. This is often referred to as building a micro-framework.

Framework vs. Micro-Framework Structure

In a framework, *it* calls your code. You write controllers and register them, and the framework lifecycle executes them. In a micro-framework, *your* code calls the packages. You retain full control over index execution paths and project files.

---

Project Layout and Directory Architecture

A clean directory layout isolates sensitive logic from public entry files. Only the public folder should be accessible by the web server (Apache/Nginx).

Here is our application directory structure:

markdown
12345678910111213
├── app/
│   ├── Controllers/      # Handles incoming HTTP requests and updates views
│   ├── Core/             # Base class libraries (Router, Container, etc.)
│   ├── Middleware/       # Request filters (Auth, CSRF, Logging)
│   ├── Models/           # DB queries and models
│   └── Services/         # Business logic units
├── config/               # DB credentials and route configs
├── public/               # Webroot directory
│   ├── index.php         # Front Controller entry point
│   └── assets/           # CSS, JS, and image assets
├── views/                # HTML template files
├── composer.json         # Autoloader configurations
└── .env                  # Environment configurations

---

Composer, PSR-4 Autoloading, and Environments

To load our PHP classes without writing nested require_once statements, we will use Composer's PSR-4 Autoloader standard.

Setting Up Composer

Create a composer.json file in the root directory:
json
123456789101112
{
    "name": "custom/framework",
    "description": "A modern PHP micro-framework from scratch",
    "require": {
        "php": ">=8.1"
    },
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}

Run composer dump-autoload to generate the class autoload paths.

---

The Front Controller & Entry Point

A Front Controller pattern routes all incoming web requests through a single entry file, usually public/index.php. This centralized file handles bootstrap configurations, loads environments, and dispatches the request to the router.

Writing the Bootstrap Entry File

php
1234567891011121314151617181920212223242526272829303132
// public/index.php
declare(strict_types=1);

define('ROOT_PATH', dirname(__DIR__));

// 1. Load Composer Autoloader
require_once ROOT_PATH . '/vendor/autoload.php';

// 2. Initialize error configurations
ini_set('display_errors', '0');
ini_set('log_errors', '1');
error_reporting(E_ALL);

// 3. Start Session securely
session_start([
    'cookie_httponly' => true,
    'cookie_secure'   => true,
    'cookie_samesite' => 'Strict'
]);

// 4. Initialize Dependency Container and Router
$container = new \App\Core\Container();
$router = new \App\Core\Router($container);

// 5. Load routes configurations
require_once ROOT_PATH . '/routes/web.php';

// 6. Run the Router with active URI
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$method = $_SERVER['REQUEST_METHOD'];

$router->dispatch($method, $uri);

---

Building a Router and Dispatcher

The Router parses incoming HTTP requests, matches the requested URL and HTTP method against registered paths, and dispatches execution to the corresponding controller.

Let's write a simple, powerful router supporting custom middleware:

php
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// app/Core/Router.php
namespace App\Core;

use Exception;

class Router {
    private array $routes = [];
    private Container $container;

    public function __construct(Container $container) {
        $this->container = $container;
    }

    // Register a GET route
    public function get(string $path, string $handler, array $middleware = []): void {
        $this->routes['GET'][$path] = ['handler' => $handler, 'middleware' => $middleware];
    }

    // Register a POST route
    public function post(string $path, string $handler, array $middleware = []): void {
        $this->routes['POST'][$path] = ['handler' => $handler, 'middleware' => $middleware];
    }

    // Dispatch request
    public function dispatch(string $method, string $uri): void {
        $routesForMethod = $this->routes[$method] ?? [];

        foreach ($routesForMethod as $path => $route) {
            // Simple match (can be updated to support regex parameters)
            if ($path === $uri) {
                $this->execute($route);
                return;
            }
        }

        // Return 404
        http_response_code(404);
        echo "404 - Page Not Found";
    }

    private function execute(array $route): void {
        // 1. Run Middlewares first
        foreach ($route['middleware'] as $mwClass) {
            $middleware = $this->container->get($mwClass);
            if (!$middleware->handle()) {
                exit; // Stop execution if middleware verification fails
            }
        }

        // 2. Dispatch Controller
        list($controllerClass, $method) = explode('@', $route['handler']);
        $controllerClass = "App\\Controllers\\" . $controllerClass;

        if (!class_exists($controllerClass)) {
            throw new Exception("Controller {$controllerClass} not found.");
        }

        // Fetch controller instance from Dependency Container
        $controller = $this->container->get($controllerClass);

        if (!method_exists($controller, $method)) {
            throw new Exception("Method {$method} not found in {$controllerClass}.");
        }

        // Call the action method
        $controller->$method();
    }
}

---

Implementing a Dependency Injection (DI) Container

Hardcoding dependencies inside class constructors makes unit testing difficult and increases coupling. A Dependency Injection Container automates class instantiation, resolves dependencies, and returns configured instances.

Let's build a simple, lightweight DI Container:

php
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566
// app/Core/Container.php
namespace App\Core;

use ReflectionClass;
use Exception;

class Container {
    private array $bindings = [];
    private array $instances = [];

    // Bind a class or interface name to a resolver closure
    public function bind(string $key, callable $resolver): void {
        $this->bindings[$key] = $resolver;
    }

    // Retrieve instance
    public function get(string $class) {
        // If singleton instance exists, return it
        if (isset($this->instances[$class])) {
            return $this->instances[$class];
        }

        // If custom binding is registered, use the resolver
        if (isset($this->bindings[$class])) {
            return $this->bindings[$class]($this);
        }

        // Auto-resolve class dependencies using Reflection
        return $this->resolve($class);
    }

    private function resolve(string $class) {
        $reflector = new ReflectionClass($class);

        if (!$reflector->isInstantiable()) {
            throw new Exception("Class {$class} is not instantiable.");
        }

        $constructor = $reflector->getConstructor();
        if ($constructor === null) {
            return new $class();
        }

        $parameters = $constructor->getParameters();
        $dependencies = [];

        foreach ($parameters as $parameter) {
            $dependencyType = $parameter->getType();

            if ($dependencyType === null) {
                throw new Exception("Cannot resolve parameter without type hint in {$class}.");
            }

            if ($dependencyType->isBuiltin()) {
                throw new Exception("Cannot auto-resolve built-in parameter types in {$class}.");
            }

            // Recursively resolve dependency
            $dependencies[] = $this->get($dependencyType->getName());
        }

        $instance = $reflector->newInstanceArgs($dependencies);
        $this->instances[$class] = $instance; // Cache as singleton
        return $instance;
    }
}

---

The Controller Layer and MVC Design

Let's build the Model-View-Controller (MVC) layer. Our controllers will receive dependencies through their constructors, retrieve data using Models, and render views.

The Base Controller Class

php
123456789101112131415161718192021222324
// app/Core/Controller.php
namespace App\Core;

abstract class Controller {
    protected function view(string $template, array $data = []): void {
        $viewPath = ROOT_PATH . "/views/{$template}.php";

        if (!file_exists($viewPath)) {
            http_response_code(500);
            die("View template {$template} not found.");
        }

        // Extract associative data as local variables
        extract($data);

        // Capture output buffer
        ob_start();
        require $viewPath;
        $content = ob_get_clean();

        // Include master layout template
        require ROOT_PATH . '/views/layout.php';
    }
}

Implementing a Concrete Controller

php
12345678910111213141516171819202122
// app/Controllers/HomeController.php
namespace App\Controllers;

use App\Core\Controller;
use App\Models\PostModel;

class HomeController extends Controller {
    private PostModel $postModel;

    public function __construct(PostModel $postModel) {
        $this->postModel = $postModel;
    }

    public function index(): void {
        $posts = $this->postModel->getLatestPosts();
        
        $this->view('home', [
            'title' => 'Welcome to our Custom Framework Site',
            'posts' => $posts
        ]);
    }
}

---

Pipeline Middleware Architecture

Middleware processes incoming HTTP requests before they reach your controller actions. They are ideal for tasks like authentication, logging, and security verification.

Middleware Interface

php
123456
// app/Middleware/MiddlewareInterface.php
namespace App\Middleware;

interface MiddlewareInterface {
    public function handle(): bool;
}

Auth Middleware Implementation

php
12345678910111213
// app/Middleware/AuthMiddleware.php
namespace App\Middleware;

class AuthMiddleware implements MiddlewareInterface {
    public function handle(): bool {
        if (!isset($_SESSION['logged_in']) || $_SESSION['logged_in'] !== true) {
            // Redirect to login page
            header("Location: /login");
            return false;
        }
        return true;
    }
}

---

Integrating the Database Layer

Instead of calling raw PDO queries inside our controllers, we will inject a database connection wrapper.
php
123456789101112131415161718192021
// app/Core/Database.php
namespace App\Core;

use PDO;

class Database {
    private PDO $connection;

    public function __construct() {
        $dsn = "mysql:host=127.0.0.1;dbname=tutorials_db;charset=utf8mb4";
        $this->connection = new PDO($dsn, 'root', '', [
            PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
            PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
            PDO::ATTR_EMULATE_PREPARES => false
        ]);
    }

    public function getConnection(): PDO {
        return $this->connection;
    }
}

Now we inject the Database class into our model:

php
1234567891011121314151617
// app/Models/PostModel.php
namespace App\Models;

use App\Core\Database;

class PostModel {
    private Database $db;

    public function __construct(Database $db) {
        $this->db = $db;
    }

    public function getLatestPosts(): array {
        $pdo = $this->db->getConnection();
        return $pdo->query("SELECT * FROM blog_posts WHERE status = 'Published' ORDER BY created_at DESC LIMIT 5")->fetchAll();
    }
}

---

Common Mistakes and Pitfalls

1. Global State Abuse

Avoid calling $GET, $POST, or $_SERVER directly in deep services classes. Instead, encapsulate them inside a request wrapper class (e.g. App\Core\Request) and pass that request object into your controllers. This keeps your code modular and easier to test.

2. Manual Autoloading

Never write complex custom autoloading algorithms. Composer's autoloader is optimized, handles caching, and follows PSR-4 standards natively.

---

Performance and Production Tuning

  • OPcache: In production, enable OPcache in your PHP configuration (php.ini). This caches compiled script bytecode in memory, bypassing compilation steps on subsequent requests.
  • Composer Optimization: Deploy with optimized class loading configurations:
``bash composer install --no-dev --optimize-autoloader --classmap-authoritative `

---

Frequently Asked Questions (FAQs)

Why should I write routing from scratch instead of using regex libraries?

For simple websites, writing a basic router is an excellent way to understand front controllers. However, for complex web systems with dynamic regex arguments (e.g.,
/blog/{category}/{slug}), using a proven package like FastRoute is safer and faster.

Is a custom framework secure?

Yes, provided you follow secure programming practices: use prepared statements, escape all outputs to prevent XSS, use CSRF protection tokens, configure secure session headers, and handle file uploads safely.

---

Key Takeaways

  1. 1. The Front Controller Pattern: Route all requests through a single entry file (public/index.php`) to keep bootstrap configurations centralized.
  1. 2. Class Autoloading: Use Composer and the PSR-4 standard to keep classes modular and avoid manually including files.
  1. 3. Dependency Injection: Inject dependencies through constructors rather than instantiating classes directly.
  1. 4. Pipeline Middleware: Validate authentication and CSRF security before executing controller logic.

---

G

About the Author: gs_admin

A senior technical contributor specializing in architectural designs, software optimization, database structures, and developer education. Passionate about writing clean code and sharing engineering knowledge.