PoC PHP MVC Framework with Custom Router, IoC/DI Container (PSR-compliant)

πŸ”¬ Proof of Concept of the MVC in PHP

PoC PHP MVC Framework with Custom Router, IoC/DI Container (PSR-compliant)

This project is a Proof of Concept (PoC) for a custom-built PHP MVC framework that adheres to several PHP-FIG standards, including PSR-1, PSR-2/PSR-12, PSR-4, and PSR-11. It demonstrates how to build a lightweight MVC structure with custom routing, dependency injection (IoC/DI), session management, and static-asset bundling, without relying on a full-blown framework.

Table of Contents

Introduction

In this PoC, we build a small MVC application from scratch using PSR-compliant components. This includes a custom router (with base-path support, dynamic {param} segments, static-file serving, and trailing-slash redirects), a basic dependency injection container (constructor autowiring via Reflection), an organized controller/view structure with layouts and sections, and a couple of RESTful JSON API controllers.

The goal is to demonstrate clean, maintainable architecture by implementing industry-standard interfaces and coding practices, using Composer for PSR-4 autoloading.

PSRs in Use

The project complies with the following PSRs:

Directory Structure

poc-php-mvc/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ Config/
β”‚   β”‚   β”œβ”€β”€ BundleRegistration.php     # Registers static asset bundles (CSS/JS)
β”‚   β”‚   └── Registration.php           # Registers services (DI) and routes
β”‚   β”œβ”€β”€ Controllers/
β”‚   β”‚   β”œβ”€β”€ AboutController.php        # About page
β”‚   β”‚   β”œβ”€β”€ ApiController.php          # GET /api/v1 -> dumps $_SERVER as JSON
β”‚   β”‚   β”œβ”€β”€ AuthController.php         # Login/logout against an in-memory user list
β”‚   β”‚   β”œβ”€β”€ ContactController.php      # Contact form (show + submit)
β”‚   β”‚   β”œβ”€β”€ HomeController.php         # Home, docs, sandbox, sections pages
β”‚   β”‚   β”œβ”€β”€ UsersApiController.php     # REST API for users (index/show/create/update/destroy)
β”‚   β”‚   └── UsersController.php        # Server-rendered user list/detail pages
β”‚   β”œβ”€β”€ Models/
β”‚   β”‚   β”œβ”€β”€ ContactModel.php           # Contact form data + validation
β”‚   β”‚   └── UserModel.php              # In-memory user data
β”‚   └── Views/
β”‚       β”œβ”€β”€ About/index.php
β”‚       β”œβ”€β”€ Auth/login.php
β”‚       β”œβ”€β”€ Contact/index.php
β”‚       β”œβ”€β”€ Home/{index,docs,sandbox,sections}.php
β”‚       β”œβ”€β”€ Shared/layout.php          # Shared layout wrapping all views
β”‚       └── Users/{index,show}.php
β”œβ”€β”€ nginx/
β”‚   β”œβ”€β”€ Dockerfile                     # PHP-FPM image (php:8.3-fpm) used by the *-prod compose file
β”‚   └── nginx.conf                     # Proxies .php requests to php-fpm:9000
β”œβ”€β”€ public/
β”‚   β”œβ”€β”€ assets/{scripts.js,styles.css}
β”‚   └── index.php                      # Front controller / application entry point
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ Container/DIContainer.php      # PSR-11 DI container with constructor autowiring
β”‚   β”œβ”€β”€ Controller/
β”‚   β”‚   β”œβ”€β”€ ApiBaseController.php      # Base for JSON API controllers (view() -> json())
β”‚   β”‚   └── BaseController.php         # Base for HTML controllers (view/layout/sections)
β”‚   β”œβ”€β”€ Core/
β”‚   β”‚   β”œβ”€β”€ Application.php            # Bootstraps container + router, run()/error handling
β”‚   β”‚   β”œβ”€β”€ BundleManager.php          # Static registry of named asset bundles
β”‚   β”‚   β”œβ”€β”€ HttpException.php          # Exception carrying an HTTP status code
β”‚   β”‚   └── SessionManager.php         # Thin wrapper around PHP sessions
β”‚   └── Router/Router.php              # Route matching, dispatch, API auto-registration
β”œβ”€β”€ tests/
β”‚   β”œβ”€β”€ phpunit.xml                    # PHPUnit configuration (unit + integration suites)
β”‚   β”œβ”€β”€ Integration/{FullAppTest,HomeControllerTest}.php
β”‚   └── Unit/{DIContainerTest,RouterTest}.php
β”œβ”€β”€ Dockerfile / Dockerfile-dev         # Apache images (php:8.5-apache)
β”œβ”€β”€ docker-compose*.yml                 # See "Running the Project with Docker"
β”œβ”€β”€ vercel.json                         # Vercel deployment config
β”œβ”€β”€ composer.json
└── README.md

Architecture Overview

Application

src/Core/Application.php is the composition root. Its constructor takes an optional $basePath (default '') and $publicDirBasePath (default 'public/'), and builds a DIContainer and a Router wired to that container and those paths. run() dispatches the current request ($_SERVER['REQUEST_METHOD']/REQUEST_URI) through the router, echoes the response, and centralizes error handling:

Router

src/Router/Router.php supports:

DIContainer

src/Container/DIContainer.php implements Psr\Container\ContainerInterface:

BaseController / ApiBaseController

src/Controller/BaseController.php is the base for HTML controllers. Its constructor takes a $viewsPath and derives a view search order of {viewsPath}/{ControllerName}/, {viewsPath}/Shared/, then {viewsPath}/ (the Controller suffix is stripped from the calling class name). Key protected methods:

src/Controller/ApiBaseController.php extends BaseController but overrides view() to always call json($data) regardless of the view name/layout arguments β€” i.e. any controller extending it is JSON-only by construction. ApiController uses this base directly; UsersApiController extends it too but implements its own jsonResponse() helper for status codes instead of using the inherited json().

BundleManager and SessionManager

src/Core/BundleManager.php is a static registry: register($bundleName, $assets), getBundle($bundleName), getAllBundles() β€” used by BundleRegistration and renderBundles().

src/Core/SessionManager.php is a static, thin wrapper over native PHP sessions: start(), set(), get(), has(), remove(), destroy(), regenerate(). Used by AuthController (login/logout) and UsersController (gating /users behind a logged-in session).

Registered Routes

All routes below are registered in app/Config/Registration.php. Remember that public/index.php constructs new Application('/poc-php-mvc'), so as shipped every path is actually served under the /poc-php-mvc prefix (e.g. /poc-php-mvc/about) β€” see Getting Started if you want to run it at the domain root instead.

HTML (SSR) Routes

Method Path Controller::method
GET / HomeController::index
GET /docs HomeController::docs
GET /sandbox HomeController::sandbox
GET /sections HomeController::sections
GET /login AuthController::login
POST /login AuthController::login
GET /logout AuthController::logout
GET /about AboutController::index
GET /contact ContactController::showForm
POST /contact/submit ContactController::handleFormSubmission
GET /users UsersController::index (requires a logged-in session)
GET /users/{id} UsersController::show (requires a logged-in session)
GET /api/v1 ApiController::index

API Routes

Auto-registered via $router->registerApiController(UsersApiController::class):

Method Path Controller::method
GET /api/v1/users UsersApiController::index
GET /api/v1/users/{id} UsersApiController::show
POST /api/v1/users UsersApiController::create
PUT /api/v1/users/{id} UsersApiController::update

UsersApiController::destroy() exists but is not reachable β€” see Known Limitations.

Example Code

app/Controllers/HomeController.php

<?php

namespace GuiBranco\PocMvc\App\Controllers;

use GuiBranco\PocMvc\Src\Controller\BaseController;

class HomeController extends BaseController
{
    public function index()
    {
        return $this->view('index', ['title' => 'Home Page']);
    }

    public function docs()
    {
        return $this->view('docs', ['title' => 'Documentation']);
    }

    public function sandbox()
    {
        return $this->view('sandbox', ['title' => 'Sandbox']);
    }

    public function sections()
    {
        return $this->view('sections', ['title' => 'Sections']);
    }
}

app/Config/Registration.php

<?php

namespace GuiBranco\PocMvc\App\Config;

use GuiBranco\PocMvc\App\Controllers\HomeController;
use GuiBranco\PocMvc\Src\Core\Application;

class Registration
{
    protected $router;
    protected $container;

    public function __construct(Application $app)
    {
        $this->router = $app->getRouter();
        $this->container = $app->getContainer();
    }

    public function addServices(): void
    {
        $viewsPath = __DIR__ . '/../Views';
        $this->container->set(HomeController::class, fn ($c) => new HomeController($viewsPath));
        // ... other controllers registered the same way
    }

    public function registerRoutes(): void
    {
        $this->router->add('GET', '/', [$this->container->get(HomeController::class), 'index']);
        // ... remaining routes, see the full table above
    }
}

public/index.php

The front controller wires everything together and dispatches the request:

<?php

use GuiBranco\PocMvc\App\Config\BundleRegistration;
use GuiBranco\PocMvc\App\Config\Registration;
use GuiBranco\PocMvc\Src\Core\Application;

require_once __DIR__ . '/../vendor/autoload.php';

$app = new Application('/poc-php-mvc'); // base path the app is mounted under
$registration = new Registration($app);
$registration->addServices();          // register controllers in the DI container
$registration->registerRoutes();       // register HTML routes
$registration->registerApiControllers(); // auto-register the users REST API
$bundleRegistration = new BundleRegistration();
$bundleRegistration->registerBundles(); // register CSS/JS asset bundles
$app->run();                           // dispatch the request and print the response

Requirements

Getting Started

  1. Clone the repository:

    git clone https://github.com/GuilhermeStracini/poc-php-mvc.git
    cd poc-php-mvc
    
  2. Install dependencies:

    composer install
    
  3. Run the application with PHP’s built-in server:

    php -S localhost:8000 -t public
    
  4. Access the application. public/index.php mounts the app under the /poc-php-mvc base path by default, so browse to:

    Note the trailing slash: any bare GET request without one is 301-redirected to the trailing-slash form by the router.

    If you’d rather serve the app at the domain root (no /poc-php-mvc prefix), change the Application constructor call in public/index.php to new Application() (empty base path).

  5. Log in to access /users and /users/{id}: AuthController validates against an in-memory user list (john / password123, jane / securePass) β€” this is a PoC, replace it with real authentication before using this anywhere real.

Environment Variables

The only environment variable the code reads directly is:

There is no .env file loader in this project (no vlucas/phpdotenv or similar dependency) β€” set environment variables through your shell, your web server configuration, or your Docker Compose file’s environment: block.

Running the Project with Docker

Five Compose files are provided, split by web server and by dev/prod intent:

File Web server Image Notes
docker-compose.yml Apache Dockerfile (PHP 8.5) Baseline, no volume mount
docker-compose-apache-prod.yml Apache Dockerfile (PHP 8.5) Same as above
docker-compose-apache-dev.yml Apache Dockerfile-dev (PHP 8.5) Mounts the repo as a volume for live editing
docker-compose-nginx-prod.yml NGINX + PHP-FPM nginx/Dockerfile (PHP 8.3-FPM) Β 
docker-compose-nginx-dev.yml NGINX + PHP-FPM nginx/Dockerfile-dev ⚠️ this file does not currently exist in nginx/ β€” see Known Limitations

Prerequisites

Ensure you have Docker and Docker Compose installed on your machine.

Running with Apache

docker compose -f docker-compose-apache-dev.yml up --build

Then access the application at http://localhost:8080 (remember the /poc-php-mvc base path noted in Getting Started). Stop the containers with:

docker compose -f docker-compose-apache-dev.yml down

Running with NGINX and PHP-FPM

docker compose -f docker-compose-nginx-prod.yml up --build

Then access the application at http://localhost:8080. Stop the containers with:

docker compose -f docker-compose-nginx-prod.yml down

Deploy to Vercel

You can deploy this project to Vercel with one click. Vercel is a good fit for hosting PHP projects with minimal configuration.

Deploy to Vercel

Steps to Deploy

  1. Click the β€œDeploy to Vercel” button above, or go to Vercel Import.
  2. Import your GitHub repository by linking your GitHub account.
  3. Configure your project settings:
    • Leave the Root Directory as the project root.
    • Set the Output Directory to public/, since that’s where index.php lives.
  4. Deploy β€” the app will be live in a few seconds.

Vercel Configuration

vercel.json routes every request to the front controller:

{
  "version": 2,
  "builds": [
    {
      "src": "public/index.php",
      "use": "@vercel/php"
    }
  ],
  "routes": [
    {
      "src": "/(.*)",
      "dest": "public/index.php"
    }
  ]
}

Continuous Integration

GitHub Actions workflows in .github/workflows/:

Code style is enforced via PHP CS Fixer (see .deepsource.toml).

Tests

Unit and integration tests validate the core framework and application behavior. Run the full suite with:

vendor/bin/phpunit --configuration tests/phpunit.xml

Tests are split into two suites, defined in tests/phpunit.xml:

Run a single suite with --testsuite:

vendor/bin/phpunit --configuration tests/phpunit.xml --testsuite unit
vendor/bin/phpunit --configuration tests/phpunit.xml --testsuite integration

Known Limitations

This is a PoC, and a few rough edges are worth knowing about rather than papering over:

License

MIT β€” see LICENSE.