A Keep-It-Simple-Stupid PHP MVC Framework
By Joe Fallon
KissMVC is a lightweight, fast, bare-bones MVC framework for PHP 7.4+ that follows the KISS principle. Use it as a skeleton to build modern web applications with minimal overhead and maximum clarity.
"Make everything as simple as possible, but not simpler."
— Albert Einstein
"Simplicity is prerequisite for reliability."
— Edsger W. Dijkstra
"Fools ignore complexity; pragmatists suffer it; experts avoid it; geniuses remove it."
— Alan Perlis
- Features
- Requirements
- Quick Start
- Installation
- Project Structure
- Architecture Overview
- Core Concepts
- Configuration
- Adding a New Page
- Server Configuration
- Development Workflow
- Testing
- Contributing
- License
- Minimal overhead: Five core classes, zero bloat.
- Standard folder structure: Organized by responsibility (MVC pattern).
- Simple routing: One URL segment maps to one controller. Easy to trace.
- Secure by default: All public assets live in a single
public/directory; application code is not web-accessible. - Fast learning curve: 20-25 minutes to understand the entire framework.
- Best practices: Promotes migrations, gateways, models, controllers, layouts, views, and partials.
- Composer-friendly: Bring your own ORM, logger, or libraries.
- PHP 7.4+ ready: Strict types, typed properties, and modern syntax.
- PHP 7.4 or higher (8.0+ recommended)
- Composer for dependency management
- Web server: Nginx, Apache, or PHP's built-in server
- Optional: Database (MySQL, PostgreSQL, SQLite, etc.)
# 1. Clone or download the repository
git clone https://github.com/yourusername/KissMVC.git myapp
cd myapp/website
# 2. Install dependencies
composer install
# 3. Configure your application
src/Config/main.phpYou should see the default "Hello, World!" page.
KissMVC is both a small framework library and a folder structure for organizing your application. It is not distributed as a standalone Composer package; instead, you clone or download the entire skeleton.
- Download or clone this repository.
- Copy the
website/folder into your project repository. - Rename
website/to match your application name (optional). - Run
composer installinside the folder to install dependencies. - Configure your web server to point the document root to
public/. - Edit configuration files in
application/config/to match your environment.
YourAppName/
│
├── src/ # Application code (not web-accessible)
│ ├── Bootstrapper.php # App initialization (DB, services, etc.)
│ ├── Config/
│ │ ├── main.php # Main configuration (DB, paths, timezone)
│ │ └── routes.php # Route definitions (URL → Controller map)
│ ├── Controllers/ # Page controllers (one per page)
│ │ ├── IndexController.php
│ │ ├── IndexControllerFactory.php
│ │ ├── PageWithParametersController.php
│ │ └── PageWithParametersControllerFactory.php
│ ├── Domain/ # Business logic classes (shared across models)
│ ├── Entities/ # Data objects representing DB rows
│ ├── Gateways/ # Table gateways (CRUD for DB tables)
│ ├── Layouts/ # Page layout templates (e.g. default.php)
│ ├── Models/ # Page-specific models (orchestrate domain logic)
│ ├── Partials/ # Reusable view snippets (e.g. header, footer)
│ └── Views/ # Page-specific view templates
│
├── db/
│ └── migrations/ # Database migration scripts
│
├── lib/
│ └── KissMVC/ # Framework core (5 classes, with Bootstrapper)
│ ├── Application.php
│ ├── Controller.php
│ ├── ControllerFactoryInterface.php
│ └── FrontController.php
│
├── public/ # Web-accessible directory (document root)
│ ├── index.php # Front controller entry point
│ ├── .htaccess # Apache rewrite rules (optional)
│ ├── css/ # Stylesheets
│ ├── img/ # Images
│ └── js/ # JavaScript files
│
├── tests/ # Unit and integration tests
│ ├── index.php # Test runner (optional)
│ ├── Config/ # Test configuration
│ ├── Controllers/ # Controller tests
│ ├── Domain/ # Domain class tests
│ ├── Entities/ # Entity tests
│ ├── Gateways/ # Gateway tests
│ ├── Lib/ # Test-specific libraries
│ └── Models/ # Model tests
│
├── vendor/ # Composer dependencies (gitignored)
├── composer.json # Composer dependencies
├── composer.lock # Locked dependency versions
└── README.md # This file
KissMVC uses the Front Controller pattern combined with MVC (Model-View-Controller). Here's how a request flows through the system:
┌─────────────────────────────────────────────────────────────────────┐
│ Request Flow │
└─────────────────────────────────────────────────────────────────────┘
┌───────────────┐
│ User Browser │
└───────────────┘
│
│ HTTP Request: /page-with-parameters/abc/123
▼
┌─────────────────┐
│ Web Server │ (Nginx/Apache)
│ (Document Root │ Routes all non-static requests to:
│ = public/) │ public/index.php
└────────┬────────┘
│
▼
┌─────────────────┐
│ public/ │ 1. Require Composer autoloader
│ index.php │ 2. Define constants (BASE_PATH, APP_PATH)
└────────┬────────┘ 3. Load config: Application::loadConfiguration()
│ 4. Run: Application::run()
▼
┌─────────────────┐
│ Application │ - Check SSL requirement
│ ::run() │ - Set timezone
└────────┬────────┘ - Instantiate FrontController
│
▼
┌─────────────────┐
│ FrontController │ - Parse URL segments
│ ::routeRequest()│ - Call routeToController($segment)
└────────┬────────┘ - Get Controller instance (or null → 404)
│
▼
┌───────────────────────┐
│ routes.php │ Returns a Controller based on route name.
│ function │ Example: 'default' → IndexControllerFactory::create()
│ routeToController() │
└────────┬──────────────┘
│
▼
┌───────────────────┐
│ ControllerFactory │ Factory instantiates the controller with
│ ::create() │ dependencies (models, services, etc.).
└────────┬──────────┘
│
▼
┌─────────────────┐
│ Controller │ - setRequestParameters([...])
│ (concrete) │ - execute() ← Page-specific logic here
└────────┬────────┘ - renderLayout()
│
▼
┌──────────────────────┐
│ Layout │ - Includes header, footer, wrapper HTML
│ (e.g. default.php) │ - Calls $this->renderView()
└────────┬─────────────┘
│
▼
┌────────────────────┐
│ View │ - Page-specific HTML template
│ (e.g. index.php) │ - Accesses controller public methods/helpers
│ │ - May include partials
└────────┬───────────┘
│
▼
HTML Response → User Browser
- One controller per page: Simple, predictable routing.
- Factory pattern: Controllers are instantiated via factories for clean dependency injection.
- Separation of concerns: Models handle business logic, views handle presentation, controllers coordinate.
Routes are defined in src/Config/routes.php. The router maps a
single URL segment to a controller.
Example URL:
http://myapp.com/page-with-parameters/abc/123/xyz
^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^
Route name Parameters
- Route name:
page-with-parameters - Parameters:
['abc', '123', 'xyz']
routes.php:
function routeToController(string $route): ?Controller
{
// Normalize the incoming route string.
$route = strtolower(trim($route));
// Simple default behaviour: empty route maps to 'default'.
if($route === '')
{
// The default route must always exist in the $routes map below. In other frameworks
// this might be called 'home' or 'index'.
$route = 'default';
}
switch($route)
{
case 'default':
return IndexControllerFactory::create();
case 'page-with-parameters':
return PageWithParametersControllerFactory::create();
// case 'view-items':
// return ViewItemsControllerFactory::create();
default:
return null;
}
}To add a new route:
- Create a controller class (e.g.
AboutController). - Create a factory class (e.g.
AboutControllerFactory). - Add an entry to the
$routesswitch:case 'view-items': return ViewItemsControllerFactory::create();
Controllers are page-specific classes that:
- Configure page metadata (title, layout, view).
- Orchestrate models and services in
execute(). - Provide helper methods for views (e.g.
getMessage()).
Example: IndexController.php
<?php
declare(strict_types=1);
namespace Controllers;
use KissMVC\Controller;
class IndexController extends Controller
{
public function __construct()
{
parent::__construct();
$this->setPageTitle('Home');
$this->setLayout('default.php');
$this->setView('index.php');
}
public function execute(): void
{
parent::execute(); // Intentional no-op; silences IDE warnings
// Fetch data, call models, prepare for view
}
public function getMessage(): string
{
return 'Hello, World!';
}
}Controller lifecycle:
- Instantiation (via factory)
- setRequestParameters(...) (FrontController injects URL params)
- execute() (your business logic runs here)
- renderLayout() (layout is included; layout calls renderView())
Factories provide a single place to wire up dependencies for controllers.
They implement ControllerFactoryInterface.
Example: IndexControllerFactory.php
<?php
declare(strict_types=1);
namespace Controllers;
use KissMVC\ControllerFactoryInterface;
class IndexControllerFactory implements ControllerFactoryInterface
{
public static function create()
{
// Optionally inject dependencies:
// $model = new IndexModel($someService);
// return new IndexController($model);
return new IndexController();
}
}Layouts wrap views with common HTML structure (header, footer, nav).
Example: src/Layouts/default.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title><?= htmlspecialchars($this->getPageTitle() ?? 'App') ?></title>
<?php foreach ($this->getCssFiles() as $css): ?>
<link rel="stylesheet" href="<?= htmlspecialchars($css) ?>">
<?php endforeach; ?>
</head>
<body>
<div class="container">
<?php $this->renderView(); ?>
</div>
<?php foreach ($this->getJavaScriptFiles() as $js): ?>
<script src="<?= htmlspecialchars($js) ?>"></script>
<?php endforeach; ?>
</body>
</html>Views contain page-specific HTML.
Example: src/Views/index.php
<?php /* @var $this Controllers\IndexController */ ?>
<h1>Welcome to KissMVC</h1>
<p><?= htmlspecialchars($this->getMessage()) ?></p>
<ul>
<li>
<a href="/page-with-parameters/abc/123/xyz">
Page with Parameters
</a>
</li>
</ul>
<?php
// Include a partial
$this->renderPartial('test.php', ['data' => 'Example Data']);
?>Partials are reusable snippets.
Example: src/Partials/test.php
<div class="alert">
<p>Partial says: <?= htmlspecialchars($data['data'] ?? '') ?></p>
</div>KissMVC does not include a model or ORM layer. You are free to use:
- Doctrine ORM
- Eloquent
- PDO (raw SQL)
- Custom table gateways and entities
Recommended structure:
┌──────────────┐
│ Controller │ Orchestrates the page lifecycle
└──────┬───────┘
│ calls
▼
┌──────────────┐
│ Model │ Page-specific business logic
└──────┬───────┘
│ calls
▼
┌──────────────┐ ┌──────────────┐
│ Domain │◄─────│ Gateway │ Interacts with DB
│ Objects │ │ (CRUD) │
└──────────────┘ └──────┬───────┘
│
▼
┌──────────┐
│ Entities │ Represent DB rows
└──────────┘
Example domain structure:
- Entities:
User,Post,Comment(data objects) - Gateways:
UserGateway,PostGateway(database access) - Domain:
UserAuthenticator,PostValidator(business rules) - Models:
LoginModel,PostListModel(page orchestration)
Place these in their respective src/ subdirectories.
Configuration lives in src/Config/main.php. It returns an array of
settings consumed by Application::loadConfiguration().
Environment variables (set in .env, server config, or shell):
export APPLICATION_ENV=production
export DB_NAME=myapp_prod
export DB_HOST=prod-db.example.com
export DB_USER=app_user
export DB_PASS=secure_password
export SECRET_KEY=a-long-random-string
export SSL_REQUIRED=true
export APP_TIMEZONE=America/New_YorkFollow these steps to add a new page (e.g. "About Us"):
File: src/Controllers/AboutController.php
<?php
declare(strict_types=1);
namespace Controllers;
use KissMVC\Controller;
class AboutController extends Controller
{
public function __construct()
{
parent::__construct();
$this->setPageTitle('About Us');
$this->setLayout('default.php');
$this->setView('about.php');
}
public function execute(): void
{
parent::execute();
// Add page-specific logic here
}
public function getTeamMembers(): array
{
return ['Alice', 'Bob', 'Charlie'];
}
}File: src/Controllers/AboutControllerFactory.php
<?php
declare(strict_types=1);
namespace Controllers;
use KissMVC\ControllerFactoryInterface;
class AboutControllerFactory implements ControllerFactoryInterface
{
public static function create()
{
return new AboutController();
}
}File: src/Config/routes.php
use Controllers\AboutControllerFactory;
switch($route)
{
case 'default':
return IndexControllerFactory::create();
case 'page-with-parameters':
return PageWithParametersControllerFactory::create();
// case 'view-items':
// return ViewItemsControllerFactory::create();
default:
return null;
}File: src/Views/about.php
<?php /* @var $this Controllers\AboutController */ ?>
<h1>About Us</h1>
<h2>Team Members:</h2>
<ul>
<?php foreach ($this->getTeamMembers() as $member): ?>
<li><?= htmlspecialchars($member) ?></li>
<?php endforeach; ?>
</ul>Visit: http://localhost:8000/about
File: /etc/nginx/sites-available/myapp
server {
listen 80;
server_name myapp.local;
root /var/www/myapp/public;
index index.php index.html;
access_log /var/log/nginx/myapp-access.log;
error_log /var/log/nginx/myapp-error.log;
# Deny access to hidden files
location ~ /\. { deny all; }
# Serve static files directly
location / {
try_files $uri /index.php?$args;
}
# PHP-FPM handler
location ~ \.php$ {
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Optional: set environment variables
fastcgi_param APPLICATION_ENV production;
}
}Alternative Nginx Config Example:
server {
#listen 80; ## listen for ipv4; this line is default and implied
#listen [::]:80 default ipv6only=on; ## listen for ipv6
root /var/www/kissmvc/website/public;
index index.php index.html index.htm;
# Make site accessible from http://localhost/
server_name kissmvc.dev.joefallon.net;
autoindex off;
access_log /var/log/nginx/development-access.log;
error_log /var/log/nginx/development-error.log;
location ~ /\. { access_log off; log_not_found off; deny all; }
location ~ ~$ { access_log off; log_not_found off; deny all; }
location = /favicon.ico {
try_files $uri =204;
}
# Deny access to hidden files
location ~ /\. { deny all; }
# Serve static files directly
location / {
try_files $uri /index.php?$args;
}
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
include fastcgi_params;
# Optional: set environment variables
fastcgi_param APPLICATION_ENV development;
}
}Enable the site:
sudo ln -s /etc/nginx/sites-available/myapp /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginxFile: public/.htaccess (included by default)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [QSA,L]VirtualHost configuration:
<VirtualHost *:80>
ServerName myapp.local
DocumentRoot /var/www/myapp/public
<Directory /var/www/myapp/public>
AllowOverride All
Require all granted
</Directory>
# Optional: set environment variables
SetEnv APPLICATION_ENV production
ErrorLog ${APACHE_LOG_DIR}/myapp-error.log
CustomLog ${APACHE_LOG_DIR}/myapp-access.log combined
</VirtualHost>Enable the site:
sudo a2enmod rewrite
sudo a2ensite myapp
sudo systemctl reload apache2cd website
php -S localhost:8000 -t publicVisit: http://localhost:8000
# Install dependencies
composer install
# Run PHPUnit (if configured)
vendor/bin/phpunit --colors=always
# Lint all PHP files
for f in $(find . -name "*.php"); do php -l "$f"; doneThe repository includes scripts/ci-run.sh for automated linting and testing:
./scripts/ci-run.shThis script:
- Changes to the repository root
- Runs
composer install - Lints all PHP files
- Runs PHPUnit tests (if present)
Tests live in the tests/ directory. Structure mirrors application/:
tests/
├── Controllers/ # Controller tests
├── Domain/ # Domain class tests
├── Entities/ # Entity tests
├── Gateways/ # Gateway tests
├── Models/ # Model tests
└── Config/ # Test configuration
Example test (PHPUnit):
<?php
use PHPUnit\Framework\TestCase;
use Controllers\IndexController;
class IndexControllerTest extends TestCase
{
public function testGetMessage()
{
$controller = new IndexController();
$this->assertEquals('Hello, World!', $controller->getMessage());
}
}Contributions are welcome! Please follow these guidelines:
- Fork the repository and create a feature branch.
- Follow PSR-12 coding standards.
- Add tests for new functionality.
- Document your changes in code comments and this README if applicable.
- Run linting and tests before submitting:
./scripts/ci-run.sh
- Submit a pull request with a clear description.
KissMVC is released under the MIT License. See LICENSE file for details.
Copyright (c) 2015-2025 Joseph Fallon
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Documentation: This README and inline code documentation
Built with ❤️ and the KISS principle.