$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']);

6. Start the dev server

php bin/arc serve

Visit http://localhost:8080

### 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; prefer withAttribute() in middleware pipelines.
  • - Response adds 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.