$ ArcMVC
View on GitHub → · PHP 8.4+
A lightweight, modern PHP MVC framework. Small core, batteries included. No hidden magic —
request flow is traceable from public/index.php to response.
Every subsystem is replaceable. Secure defaults everywhere.
quick start
composer require andrewthecoder/arcmvc ### principles
- - No hidden magic. Request flow is traceable from entry point to response.
- - Every subsystem is replaceable.
- - Secure defaults everywhere.
- - Fast startup, low memory by default.
- - One canonical way to do common things.
- - First-party modules for the common website stack, each optional and independently replaceable.
### requirements
- - PHP 8.4+
- - PDO extension (for database features)
- - Composer
### getting started
1. Install
composer require andrewthecoder/arcmvc
Or scaffold from the skeleton:
cp -r vendor/andrewthecoder/arcmvc/skeleton/* .
2. Entry point
public/index.php is the entry point — everything flows through here:
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
$app = require __DIR__ . '/../bootstrap/app.php';
$app->boot();
$app->run();
3. Bootstrap
bootstrap/app.php wires things together:
<?php
declare(strict_types=1);
use Arc\Application;
use Arc\Http\Middleware\SecurityMiddleware;
$app = new Application(__DIR__ . '/../config', dirname(__DIR__));
$app->addMiddleware(SecurityMiddleware::class);
$router = $app->router();
require __DIR__ . '/../routes/web.php';
return $app;
5. Define a route
Edit routes/web.php:
$router->get('/', [HomeController::class, 'index']);
### routing
Basic routes
$router->get('/', [HomeController::class, 'index']);
$router->post('/users', [UserController::class, 'store']);
$router->put('/users/{id}', [UserController::class, 'update']);
$router->delete('/users/{id}', [UserController::class, 'delete']);
Parameterized routes
$router->get('/users/{id}', [UserController::class, 'show']);
Route parameters are passed as method arguments.
Closures
$router->get('/hello/{name}', fn (Request $req, string $name) => "Hello, {$name}!");
Return a string and it's wrapped in a Response automatically.
Route groups
$router->group(['prefix' => '/admin', 'middleware' => AuthMiddleware::class], function (Router $r) {
$r->get('/dashboard', [AdminController::class, 'dashboard']);
});
### controllers
Extend Controller for view, JSON, and redirect helpers:
class HomeController extends Controller
{
public function index(Request $request): Response
{
return $this->view('home.index', ['title' => 'Welcome']);
}
public function api(Request $request): Response
{
return $this->json(['status' => 'ok']);
}
}
Available helpers: view(), json(), redirect(), back().
### views & layouts
Views are .phtml files. $this is bound to a Template object.
View with layout
<?php /** @var \Arc\View\Template $this */ ?>
<?php $this->extend('main') ?>
<?php $this->section('title', 'Home') ?>
<h1>Welcome</h1>
Layout (layouts/main.phtml)
<?php /** @var \Arc\View\Template $this */ ?>
<!DOCTYPE html>
<html>
<head><title>
<body>
</html>
Standalone view (no layout)
<h1>
<p>No layout call, renders as-is.</p>
Template API:
- -
extend('main')— wrap this view in a layout - -
section('title', 'Home')— define a named section - -
yield('title', 'Default')— output a section or its default - -
yield('content')— output the view's rendered content - -
partial('nav.main')— render a sub-template
The @var annotation helps IDEs and static analysis understand $this.
### middleware
Implement MiddlewareInterface:
class AuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next): Response
{
if (!$this->isLoggedIn()) {
return new Response('', 401);
}
return $next($request);
}
}
Register globally:
$app->addMiddleware(AuthMiddleware::class);
Or on a route group:
$router->group(['middleware' => AuthMiddleware::class], function (Router $r) { ... });
Security headers are on by default via SecurityMiddleware.
### database
PDO under the hood. Configure in config/database.php. Supports MySQL and SQLite.
Full config
<?php
declare(strict_types=1);
return [
'default' => $_ENV['DB_CONNECTION'] ?? 'mysql',
'connections' => [
'mysql' => [
'driver' => 'mysql',
'host' => $_ENV['DB_HOST'] ?? '127.0.0.1',
'port' => $_ENV['DB_PORT'] ?? '3306',
'database' => $_ENV['DB_DATABASE'] ?? 'arc',
'username' => $_ENV['DB_USERNAME'] ?? 'root',
'password' => $_ENV['DB_PASSWORD'] ?? '',
'charset' => 'utf8mb4',
],
'sqlite' => [
'driver' => 'sqlite',
'database' => __DIR__ . '/../database/arc.sqlite',
],
],
];
Connection usage
$conn = $app->make(Connection::class);
$users = $conn->select('SELECT * FROM users WHERE active = :active', ['active' => 1]);
$id = $conn->insert('INSERT INTO users (name, email) VALUES (:name, :email)', [...]);
$affected = $conn->update('UPDATE users SET name = :name WHERE id = :id', [...]);
$conn->transaction(function (Connection $c) {
$c->insert(...);
$c->update(...);
});
ActiveRecord-style Model
class User extends Model
{
protected string $table = 'users';
protected array $fillable = ['name', 'email'];
}
User::all();
User::find(1);
User::create(['name' => 'Arc', 'email' => 'arc@example.com']);
### validation
$v = Validator::make($_POST, [
'name' => 'required|string|min:2',
'email' => 'required|email',
'password' => 'required|min:8',
'password_confirm' => 'required|same:password',
]);
if ($v->fails()) {
$errors = $v->errors();
}
$validated = $v->validated();
Available rules: required, string, integer, numeric, email, url, boolean, min:N, max:N, between:min,max, same:field, different:field, in:a,b,c, not_in:a,b,c, alpha, alpha_num, regex:pattern, date.
Custom messages:
Validator::make($data, $rules, [
'name.required' => 'Please enter your name.',
'email.email' => 'That does not look like an email.',
]);
### console commands
arc serve # Start dev server (port 8080)
arc serve --port=3000 # Custom port
arc serve --detach # Run in background
arc serve:stop # Stop detached server
arc make:controller User # Generate controller
arc make:model User # Generate model
arc routes # List registered routes
arc help # Show help
Custom commands:
class MigrateCommand extends Command
{
public function name(): string { return 'migrate'; }
public function description(): string { return 'Run migrations'; }
public function run(array $args): int {
$this->info('Running migrations...');
return 0;
}
}
// Register in bootstrap/app.php:
$kernel->register(new MigrateCommand());
### project structure
myapp/
├── app/
│ ├── Controllers/
│ ├── Models/
│ └── Middleware/
├── config/
│ ├── app.php
│ └── database.php
├── public/
│ └── index.php
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── main.phtml
│ └── home/
│ └── index.phtml
├── routes/
│ └── web.php
├── bootstrap/
│ └── app.php
└── .env
### configuration
Config files in config/. Access with dot notation:
$app->config()->get('app.name');
$app->config()->get('database.default');
$app->config()->set('app.debug', true);
$app->config()->has('app.timezone');
Environment variables via .env:
APP_NAME=Arc
APP_ENV=local
APP_DEBUG=true
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
### dependency container
Bind interfaces to implementations, resolve dependencies:
// Bind a new instance each time
$app->bind(PaymentGateway::class, StripeGateway::class);
// Bind a singleton
$app->singleton(DatabaseConnection::class, fn ($app) => new DatabaseConnection($app->config()));
// Resolve
$gateway = $app->make(PaymentGateway::class);
Use bind() for transient instances and singleton() when you need one shared instance across the request lifecycle.
### testing
Run the test suite with PHPUnit:
vendor/bin/phpunit
Arc ships with a test suite covering routing, middleware, database, validation, and views. Add your own tests in tests/.