$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 serveOr add Arc to an existing project:
composer require andrewthecoder/arcmvc:^0.9
cp -r vendor/andrewthecoder/arcmvc/skeleton/* .2. Environment
cp .env.example .envArc 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 throwInvalidArgumentException.
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. UseConnection::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'); // trueEnvironment 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 UserCustom 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/phpunitArc 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.