$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. Decoupled core with a dedicated DI container. Secure defaults:
CSRF protection, XSS escaping, SQL injection prevention, security headers.
quick start
composer create-project andrewthecoder/arcmvc my-project && cd
my-project && php bin/arc serve ### principles
- - No hidden magic. Request flow is traceable from entry point to response.
- - Decoupled core with a dedicated DI container for service management.
- - Secure defaults: CSRF, XSS escaping, SQL injection prevention, security headers.
- - 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. Create a project
composer create-project andrewthecoder/arcmvc my-project
cd my-project
php bin/arc serve
Or add Arc to an existing project:
composer require andrewthecoder/arcmvc:^0.9
cp -r vendor/andrewthecoder/arcmvc/skeleton/* .
2. Environment
cp .env.example .env
Arc includes a built-in EnvLoader. Load it early in your bootstrap:
use Arc\Config\EnvLoader;
EnvLoader::load(__DIR__ . '/../.env');
Variables are available via $_ENV and getenv(). Existing
env vars are never overwritten.
3. 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();
4. Bootstrap
bootstrap/app.php wires things
together:
<?php
declare(strict_types=1);
use Arc\Application;
use Arc\Http\Middleware\SecurityMiddleware;
use Arc\Http\Middleware\CsrfMiddleware;
$app = new Application(__DIR__ . '/../config', dirname(__DIR__));
$app->addMiddleware(SecurityMiddleware::class);
$app->addMiddleware(new CsrfMiddleware());
$router = $app->router();
require __DIR__ . '/../routes/web.php';
return $app;
5. Define a route
Edit routes/web.php:
$router->get('/', [HomeController::class, 'index']);
### HTTP request & response
Arc's HTTP primitives now support immutable-friendly patterns:
- -
Request::withAttribute(key, value)returns a cloned instance with the attribute set.setAttribute()still exists and mutates in place; preferwithAttribute()in middleware pipelines. - -
Responseadds immutable variants alongside the existing mutators:withStatusCode(int),withHeader(name, value),withContent(string),withAddedCookie(Cookie). - - When writing middleware that decorates the response (e.g., security headers), prefer the immutable methods so upstream/downstream layers don't observe unexpected mutations.
PHP 8.5 note
PHP 8.5 has a clone-chain pitfall: return clone($this)->method() may mutate $this instead of the clone. Arc's with* methods use the safer two-statement pattern ($new = clone $this; ...; return $new;). If you author your own immutable helpers, avoid one-line
clone-chains on PHP 8.5.
### routing
Basic routes
$router->get('/', [HomeController::class, 'index']);
$router->get('/users/{id}', [UserController::class, 'show']);
$router->post('/users', [UserController::class, 'store']);
$router->put('/users/{id}', [UserController::class, 'update']);
$router->delete('/users/{id}', [UserController::class, 'destroy']);
Route groups
$router->group(['prefix' => 'admin', 'middleware' =>
AuthMiddleware::class], function ($router) {
$router->get('/dashboard', [AdminController::class, 'index']);
});
Controllers are resolved through the DI container, enabling constructor injection. Route parameters are passed as method arguments.
### controllers
Extend Controller for view, JSON,
and redirect helpers:
class UserController extends Controller
{
public function show(Request $request, string $id): Response
{
$user = User::find($id);
return $this->view('users.show', ['user' =>
$user]);
}
public function store(Request $request): Response
{
return $this->redirect('/users');
}
}
Available helpers: view(), json(), redirect(), back(). The back() helper only redirects
to same-origin URLs, preventing open redirect attacks.
### views & layouts
Views are .phtml files in resources/views/. $this is bound to a Template object.
View with layout
<?php $this->extend('main') ?>
<?php $this->section('title', 'Home Page') ?>
<p>Page content here</p>
Layout (layouts/main.phtml)
<title>
<input name="name" />
</form>
CsrfMiddleware validates the
token on POST, PUT, PATCH, and DELETE. It uses the double-submit cookie
pattern with SameSite=Strict and HttpOnly.
Content and named sections yielded via yield() are raw by design (trusted template HTML). Always escape
user data with e().
### middleware
Register global middleware in your bootstrap:
use Arc\Http\Middleware\SecurityMiddleware;
use Arc\Http\Middleware\CsrfMiddleware;
use Arc\Http\Middleware\RateLimitMiddleware;
$app->addMiddleware(SecurityMiddleware::class);
$app->addMiddleware(new CsrfMiddleware());
$app->addMiddleware(new RateLimitMiddleware(maxRequests: 60, windowSeconds:
60));
Security Headers
SecurityMiddleware sets Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options, X-Frame-Options, Referrer-Policy, and
disables X-XSS-Protection.
CSP and HSTS are configurable:
new SecurityMiddleware(
csp: "default-src 'self'; script-src 'self' cdn.example.com",
hsts: 'max-age=63072000; includeSubDomains; preload'
);
CSRF Protection
CsrfMiddleware uses the double-submit
cookie pattern with SameSite=Strict and HttpOnly. The token is attached
to the request as _csrf_token and automatically passed to views via Controller::view().
Rate Limiting
RateLimitMiddleware tracks requests
per client IP:
new RateLimitMiddleware(maxRequests: 100, windowSeconds: 60);
Behind a reverse proxy, pass trustedProxies to read the client IP from X-Forwarded-For. For distributed deployments, implement RateLimitStoreInterface with Redis or a database backend.
Custom middleware
class AuthMiddleware implements MiddlewareInterface
{
public function handle(Request $request, callable $next):
Response
{
if (!$this->isLoggedIn()) {
return new Response('', 401);
}
return $next($request);
}
}
Register globally with $app->addMiddleware() or on a route group.
### query builder
QueryBuilder provides a fluent
interface for building parameterized SQL queries. All identifiers are validated
against SQL injection.
use Arc\Database\QueryBuilder;
$builder = new QueryBuilder($connection, 'users');
// SELECT with WHERE, ORDER BY, LIMIT
$users = $builder->where('active', 1)
->orderBy('name')
->limit(10)
->get();
// Operators: =, !=, <>, <, >, <=, >=, LIKE, NOT LIKE
$expensive = $builder->where('price', '>', 100)->get();
// NULL checks
$builder->whereNull('deleted_at');
$builder->whereNotNull('email');
// IN / NOT IN
$builder->whereIn('role', ['admin', 'editor']);
$builder->whereNotIn('status', ['banned']);
// First row (returns null if not found)
$user = $builder->where('id', 1)->first();
// Aggregates
$builder->count();
$builder->exists();
$builder->sum('price');
$builder->avg('price');
$builder->min('price');
$builder->max('price');
All queries use positional ? parameter
binding. Table and column names are validated against a strict regex before
interpolation.
### database
Configure in config/database.php. Supports MySQL and SQLite via PDO.
ActiveRecord-style Model
class User extends Model
{
protected string $table = 'users';
protected string $primaryKey = 'id';
protected array $fillable = ['name', 'email'];
}
User::all(limit: 50, offset: 0);
User::find($id); //
null if not found
User::findOrFail($id); // throws
if not found
User::where('email', 'user@example.com');
User::where('age', '>', 18); //
with operator
User::create(['name' => 'Arc', 'email' => '...']);
User::update($id, ['name' => 'Updated']);
User::delete($id);
User::count();
User::exists();
User::sum('age'); User::avg('age');
User::min('age'); User::max('age');
Fluent queries via query():
User::query()->where('active',
1)->orderBy('name')->limit(10)->get();
User::query()->whereNull('email')->count();
User::query()->whereIn('role', ['admin', 'editor'])->get();
Column names in where(), create(), and update() are validated
against a strict regex to prevent SQL injection. Invalid identifiers throw
InvalidArgumentException.
Raw queries
$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)', [...]);
$conn->transaction(function (Connection $c) {
$c->insert(...);
$c->update(...);
});
PDO errors are wrapped in DatabaseException to prevent sensitive SQL and table names from leaking in production. Use
Connection::ping() for connection
health checks.
### validation
$v = Validator::make($_POST, [
'name' => 'required|string|min:2',
'email' => 'required|email',
'age' => 'integer|min:18',
]);
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, [
'email.required' => 'Please enter your email',
]);
ValidationException produces human-readable
error messages (not raw JSON).
### session
use Arc\Http\Session;
$session = new Session();
$session->start();
$session->set('user_id', 42);
$session->get('user_id'); //
42
$session->has('user_id'); //
true
// Flash messages (persist for one request)
$session->setFlash('status', 'Saved!');
$session->flash('status'); // 'Saved!'
(then cleared)
$session->regenerate(); // prevent session
fixation
$session->destroy(); //
end session
### file uploads
// Simple access
$file = $request->getFile('avatar');
// Validated access (throws InvalidArgumentException on failure)
$file = $request->validateFile('avatar', maxBytes: 5_242_880, allowedMimes:
['image/jpeg', 'image/png']);
### configuration
Config files in config/,
accessed with dot notation:
$app->config()->get('app.name'); //
'Arc'
$app->config()->get('app.debug'); // false
$app->config()->set('app.theme', 'dark');
$app->config()->has('app.timezone'); // true
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:
// Explicit binding
$app->bind(PaymentGateway::class, StripeGateway::class);
// Singleton (resolved once)
$app->singleton(Logger::class, FileLogger::class);
// Auto-wiring (resolves constructor dependencies)
$controller = $app->make(UserController::class);
Constructor parameters with class types are resolved from the
container. Scalar parameters require defaults or explicit bindings.
Use bind() for transient instances
and singleton() for one shared
instance across the request lifecycle.
### error handling
In production (APP_DEBUG=false), errors are logged and a generic error page is shown. In debug
mode, full stack traces are displayed.
Database errors are wrapped in DatabaseException to prevent sensitive SQL and table names from leaking.
### cors
use Arc\Http\Middleware\CorsMiddleware;
// Allow specific origins
$app->addMiddleware(new CorsMiddleware(allowedOrigins: ['https://app.example.com']));
// Allow all origins (for APIs)
$app->addMiddleware(new CorsMiddleware(allowedOrigins: '*'));
// With credentials and custom headers
$app->addMiddleware(new CorsMiddleware(
allowedOrigins: ['https://app.example.com'],
allowCredentials: true,
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-TOKEN'],
));
Handles preflight OPTIONS requests automatically.
### http method override
Browser forms only support GET and POST. Arc supports method spoofing:
<form method="POST" action="/users/1">
<input type="hidden" name="_method" value="PUT">
<input name="name" value="Updated">
</form>
Or via API header: X-HTTP-Method-Override: PATCH
Only POST requests can be overridden to PUT, PATCH, or DELETE. Use getOriginalMethod() to see the actual HTTP method.
### console commands
arc new my-project # Create
new Arc project (scaffold + composer install)
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 route:list #
List registered routes
arc make:controller UserController
arc make:model User
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
### testing
Run the test suite with PHPUnit:
vendor/bin/phpunit
Arc ships with 270+ tests covering routing, middleware, database,
validation, views, session, and more. Add your own tests in tests/. Use Application::resetInstance() for test isolation.
License: MIT, see LICENSE. Report vulnerabilities to Security. PRs welcome, see CONTRIBUTING.md.