A lightweight, modern PHP MVC framework. Small core, batteries included, built for PHP 8.4+.

Maintainers

Package info

github.com/andrewthecodertx/php-arcmvc

Issues

pkg:composer/andrewthecoder/arcmvc

Statistics

Installs: 0

Dependents: 0

Suggesters: 0

Stars: 0

v0.9 2026-06-01 05:54 UTC

This package is auto-updated.

Last update: 2026-06-01 06:48:34 UTC


README

A lightweight, modern PHP MVC framework. Small core, batteries included, built for PHP 8.4+.

Principles

  • No hidden magic. Request flow is traceable from public/index.php to response.
  • Decoupled core: uses a dedicated DI container for service management.
  • Secure defaults: CSRF protection, 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

Quick Start

# Create a new project (installs dependencies automatically)
arc new my-project
cd my-project
arc serve

Or add Arc to an existing project:

composer require andrewthecoder/arcmvc
cp -r vendor/andrewthecoder/arcmvc/skeleton/* .

Visit http://localhost:8080

Environment Setup

Copy .env.example to .env and adjust values:

cp .env.example .env

Arc includes a built-in .env loader. Load it early in your bootstrap:

use Arc\Config\EnvLoader;
EnvLoader::load(__DIR__ . '/../.env');

Environment variables are available via $_ENV and getenv(). Existing env vars are never overwritten.

Routing

Define routes in routes/web.php:

$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 with prefix and middleware:

$router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class]], function ($router) {
    $router->get('/dashboard', [AdminController::class, 'index']);
});

Controllers

Controllers extend Arc\Support\Controller and receive the current request via setRequest():

use Arc\Support\Controller;
use Arc\Http\Request;
use Arc\Http\Response;

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');
    }

    protected function back(): Response // Safe redirect, rejects external URLs
    {
        return parent::back();
    }
}

Controllers are resolved through the DI container, enabling constructor injection.

Views and Templates

Views live in resources/views/ and use .phtml files:

<?php $this->extend('main') ?>
<?php $this->section('title', 'Home Page') ?>
<p>Page content here</p>

Layouts use yield() for content:

<title><?= $this->yield('title', 'Arc') ?></title>
<?= $this->yield('content') ?>

XSS Escaping

Use e() to escape user-supplied data:

<h1><?= $this->e($userInput) ?></h1>

Content and named sections yielded via yield() are raw by design (they contain trusted template HTML). Always escape user data with e().

CSRF Protection

Include a CSRF token in forms:

<form method="POST" action="/users">
    <?= $this->csrfField() ?>
    <input name="name" />
    <button>Save</button>
</form>

The CsrfMiddleware validates the token automatically on POST, PUT, PATCH, and DELETE requests.

Partials

<?= $this->partial('shared._nav', ['label' => 'Home']) ?>

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 headers with configurable defaults:

new SecurityMiddleware(
    csp: "default-src 'self'; script-src 'self' cdn.example.com",
    hsts: 'max-age=63072000; includeSubDomains; preload'
);

CSRF

CsrfMiddleware uses the double-submit cookie pattern with a SameSite=Strict, HttpOnly cookie (marked Secure automatically on HTTPS requests). The token is attached to the request as the _csrf_token attribute and is automatically passed to views rendered via Controller::view(), so csrfField() works without manual wiring.

Rate Limiting

RateLimitMiddleware tracks requests per client IP with configurable limits:

new RateLimitMiddleware(maxRequests: 100, windowSeconds: 60);

Behind a reverse proxy, pass the proxy IPs as trustedProxies so the client is read from X-Forwarded-For (it is ignored from untrusted peers, preventing spoofing). For stricter per-route limits, supply a keyResolver:

new RateLimitMiddleware(
    maxRequests: 5,
    windowSeconds: 60,
    trustedProxies: ['10.0.0.1'],
    keyResolver: fn ($request) => 'login:' . $request->ip(['10.0.0.1']),
);

The default in-memory store is per-process; for multi-process or distributed deployments, implement RateLimitStoreInterface with Redis or a database backend.

Query Builder

Arc\Database\QueryBuilder provides a fluent interface for building 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
$builders = $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', 'suspended']);

// First row only (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');

// INSERT
$id = $builder->insert(['name' => 'Arc', 'email' => 'arc@example.com']);

// UPDATE with WHERE
$builder->where('id', 1)->update(['name' => 'Updated']);

// DELETE with WHERE
$builder->where('id', 1)->delete();

// Select specific columns
$builder->select(['name', 'email'])->get();

Database and Models

Configure the database connection in config/database.php, then extend the Model:

use Arc\Database\Model;

class User extends Model
{
    protected string $table = 'users';
    protected string $primaryKey = 'id';
    protected array $fillable = ['name', 'email'];
}

Available methods:

User::all(limit: 50, offset: 0);               // paginated retrieval
User::find($id);                                 // single record or null
User::findOrFail($id);                           // single record or throws
User::where('email', 'user@example.com');        // conditional query
User::where('age', '>', 18);                     // with operator
User::create(['name' => 'Arc', 'email' => '...']);
User::update($id, ['name' => 'Updated']);
User::delete($id);
User::count();                                   // total count
User::exists();                                  // any rows exist
User::sum('age');                                // aggregate
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 (/^[a-zA-Z_][a-zA-Z0-9_]*$/) to prevent SQL injection. Invalid identifiers throw InvalidArgumentException.

Validation

use Arc\Validation\Validator;

$validator = Validator::make($_POST, [
    'name'  => 'required|string|min:2',
    'email' => 'required|email',
    'age'   => 'integer|min:18',
]);

if ($validator->fails()) {
    $errors = $validator->errors();
}
$validated = $validator->validated();

Available rules: required, string, integer, numeric, email, url, boolean, min, max, between, same, different, in, not_in, alpha, alpha_num, regex, date.

The regex rule uses ~ as delimiter (supports patterns containing /):

'code' => 'regex:^[a-z]+\d+$'

Custom error messages:

Validator::make($data, $rules, [
    'email.required' => 'Please enter your email',
]);

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 successfully!');
$session->flash('status');           // 'Saved successfully!' (then cleared)

$session->regenerate();              // prevent session fixation
$session->destroy();                 // end session

File Uploads

// Simple access
$file = $request->getFile('avatar');

// Validated access (checks size, MIME type, upload validity)
$file = $request->validateFile('avatar', maxBytes: 5_242_880, allowedMimes: ['image/jpeg', 'image/png']);
// Throws InvalidArgumentException on failure

Configuration

Config files live in config/ and return arrays:

// config/app.php
return [
    'name'     => $_ENV['APP_NAME'] ?? 'Arc',
    'env'      => $_ENV['APP_ENV'] ?? 'production',
    'debug'    => (bool) ($_ENV['APP_DEBUG'] ?? false),
    'url'      => $_ENV['APP_URL'] ?? 'http://localhost',
    'timezone' => 'UTC',
];

Access via the application:

$app->config()->get('app.name');      // 'Arc'
$app->config()->get('app.debug');     // false
$app->config()->set('app.theme', 'dark');

DI Container

The container supports explicit bindings, singletons, and auto-wiring:

// 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.

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

CorsMiddleware handles cross-origin requests and preflight:

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

HTTP Method Override

Browser forms only support GET and POST. Arc supports method spoofing via a hidden _method field or the X-HTTP-Method-Override header:

<form method="POST" action="/users/1">
    <?= $this->csrfField() ?>
    <input type="hidden" name="_method" value="PUT">
    <input name="name" value="Updated">
    <button>Update</button>
</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 a new Arc project (scaffold, composer install)
arc serve                # Start development server (port 8080)
arc serve --port 3000   # Custom port
arc serve --detach       # Run in background
arc serve:stop            # Stop the background server
arc route:list            # List registered routes
arc make:controller UserController
arc make:model User

License

MIT, see LICENSE.

Contributing

PRs welcome. Please open an issue first for major changes. See CONTRIBUTING.md for details.

Security

See SECURITY.md for how to report vulnerabilities.