Zero dependencies. Full control. Production-ready security.
A hand-crafted PHP 8.1+ MVC framework built for developers who want to understand every line of their stack. No magic, no bloat — just clean architecture with serious security baked in.
| Layer | Capability |
|---|---|
| Router | GET / POST / PUT / PATCH / DELETE + HTML form method spoofing, middleware groups & FormRequest injection |
| Request | Auto JSON body parsing, file upload helpers, header resolver, client IP resolver |
| Rate Limiter | Parameterized rate limiting middleware (rate_limit:100,60) with Client IP tracking |
| QueryBuilder | Fluent, fully parameterized — no raw SQL. Write guards on update() / delete(), driver-aware dialects |
| Model Relationships | hasMany, hasOne, belongsTo + eager loading (no N+1) |
| Async Job Queue | DB-backed queue with retry + exponential backoff |
| DI Container | Auto-resolution via Reflection + singleton / factory / instance bindings |
| Cache | File or Redis driver — Cache::remember() pattern |
| Events | Synchronous and async listeners — side effects stay out of controllers |
| Validator | required, email, unique, regex, nullable, confirmed, min, max, in |
| Logger | PSR-3 daily rotated file logger (storage/logs/app-YYYY-MM-DD.log) with interpolation |
| SQL Dialects | Driver-aware SQL identifier quote compiling (MySQL backticks vs SQLite double quotes) |
| FormRequests | Abstract request base with auto-injection and auto-validation in controller methods |
| Session | HttpOnly + SameSite=Lax + Secure (auto-detect) + CSRF generation |
| View | Layout + template rendering with double path-traversal guard |
| Security | CSRF (form/AJAX/JSON), XSS escape, open redirect guard, security headers middleware |
- PHP 8.1+
- MySQL / MariaDB (for DB features)
- Apache with
mod_rewriteor PHP built-in server
git clone https://github.com/blackmoon87/spartan.git
cd spartancp .env.example .envEdit .env:
APP_NAME=Spartan
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:8000
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=your_db
DB_USERNAME=root
DB_PASSWORD=# Built-in PHP server
php -S localhost:8000 -t public
# With native built-in autoloader (Zero-dependency)
php -S localhost:8000 -t public
# Or with Composer autoloader (optional)
composer dump-autoload
php -S localhost:8000 -t publicRun database migrations and seed default roles and permissions:
# Run migrations
php spartan migrate
# Seed database
php spartan db:seedStart the worker via the CLI runner:
# Single pass (use with Cron — every minute)
php spartan worker
# Continuous loop (development / daemon)
php spartan worker --loop├── config/
│ └── config.php # .env loader → config array
├── public/
│ ├── .htaccess # URL rewriting
│ └── index.php # Front controller
├── routes/
│ ├── web.php # Public routes
│ ├── admin.php # Protected routes
│ └── api.php # JSON API routes
├── src/
│ ├── Core/ # Framework kernel (do not modify)
│ │ ├── Application.php
│ │ ├── Router.php
│ │ ├── Request.php
│ │ ├── Response.php
│ │ ├── Controller.php
│ │ ├── Model.php
│ │ ├── QueryBuilder.php
│ │ ├── RelationQuery.php
│ │ ├── JobQueue.php
│ │ ├── EventDispatcher.php
│ │ ├── Container.php
│ │ ├── Cache.php
│ │ ├── Session.php
│ │ ├── Validator.php
│ │ └── View.php
│ ├── Controllers/
│ ├── Models/
│ ├── Services/
│ ├── Listeners/
│ ├── Events/
│ ├── Middlewares/
│ └── Views/
│ └── layouts/
├── storage/
│ ├── cache/
│ └── jobs.sql # Async queue table schema
├── worker.php # CLI queue worker
├── .env
├── .env.example
├── .cursorrules # AI IDE architecture rules
└── composer.json
Spartan features a robust, regex-based router that supports RESTful methods, route parameters, middleware piping, and form method spoofing.
Define routes in their respective files under the routes/ directory depending on their context:
- Web Routes (
routes/web.php): For standard browser pages (GET) and web forms (POST). - Protected/Admin Routes (
routes/admin.php): For routes requiring authentication or specific security checks. Attach middlewares to these routes:$app->router->get('/admin/dashboard', [AdminController::class, 'index'], [ SecurityHeadersMiddleware::class, AuthMiddleware::class, ]);
- API Routes (
routes/api.php): For stateless JSON API endpoints.
Capture dynamic URL segments using curly braces {param}. These are automatically extracted and passed to your controller action as arguments:
// Route Definition
$app->router->get('/users/{userId}/orders/{orderId}', [UserController::class, 'showOrder']);
// Controller Action
class UserController extends Controller {
public function showOrder(string $userId, string $orderId) {
// Automatically populated from the URL segments
}
}Native HTML forms only support GET and POST. To perform RESTful PUT, PATCH, or DELETE requests from a form, add a hidden _method field. The router intercepts this field and directs the request to the correct handler:
<form method="POST" action="/orders/42">
<!-- CSRF Protection -->
@csrf
<!-- Method Spoofing -->
<input type="hidden" name="_method" value="DELETE">
<button type="submit">Cancel Order</button>
</form>The view layer (V) acts as the presentation layer, integrating with the controller (C) by receiving structured variables, rendering layouts, and handling client-side state reactively via HTMX and Alpine.js.
In your controller, you return a rendered template by passing an associative array containing the variables:
class DashboardController extends Controller {
public function index() {
$stats = $this->model->getStatistics();
return $this->render('dashboard', [
'stats' => $stats,
'title' => 'Admin Panel'
]);
}
}Behind the scenes:
- Native PHP Views (
.php): The framework extracts the associative array into local variables using PHP'sextract()function inside theViewobject's execution context. You print them using$this->escape($title)or<?= $this->escape($title) ?>. - Blade Views (
.blade.php): The framework compiles Blade directives natively using a regex-based compiler (extracted from the spirit of BladeOne). You print variables using{{ $title }}(which is escaped by default) or{!! $title !!}(for raw, unescaped HTML).
render($view, $data): Compiles the template and wraps it inside a main layout (e.g.layouts/main_blade.blade.php), yielding the template content inside the@yield('content')block.renderViewOnly($view, $data): Compiles the template and returns only its raw HTML content without wrapping it in a layout. This is perfect for returning partial AJAX fragments to HTMX requests.
HTMX handles server-client updates without writing complex JavaScript, while Alpine.js handles local client state.
- HTMX AJAX Swap: Send a request asynchronously on keystrokes or button clicks, and swap the returned partial template directly into a DOM node:
<!-- Views/search.blade.php --> <input type="text" name="query" hx-post="/search/query" hx-trigger="keyup changed delay:300ms" hx-target="#search-results" hx-include="[name=_csrf]" placeholder="Type to search..."> <div id="search-results"> <!-- The search_results.blade.php view will be injected here --> </div>
- Controller handler (Backend): Receives the POST query, fetches filtered data, and returns only the partial view snippet:
public function searchQuery() { $query = $this->request->post('query'); $results = (new Customer)->search($query); // Render ONLY the partial list view return $this->renderViewOnly('search_results', ['users' => $results]); }
Spartan supports mapping middleware aliases and groups in Router.php and passing dynamic parameters (e.g. rate_limit:limit,window).
$app->router->get('/dashboard', [DashboardController::class, 'index'], [
'auth',
'rate_limit:100,60' // Max 100 requests per 60 seconds (IP-based)
]);The RateLimitMiddleware checks the user's IP address and rate limits the route dynamically:
- In case of violations, it returns a
429 Too Many Requestsstatus code and terminates the route cycle early. - Adds standard headers:
X-RateLimit-Limit,X-RateLimit-Remaining, andRetry-After.
Spartan's Request class encapsulates all query parameters, form data, uploaded files, and HTTP headers.
When a client sends a request with Content-Type: application/json, the request body is automatically decoded and merged. You can retrieve inputs using standard methods:
// Automatically parses JSON input: {"title": "Hello"}
$title = $this->request->input('title');
$body = $this->request->getBody();$token = $this->request->header('Authorization'); // Bearer <token>Never access $_FILES directly. Use:
$file = $this->request->file('avatar'); // Retrieves file array
$allFiles = $this->request->getFiles();
// Upload path configured in config/config.php
$uploadDir = Application::$app->config['storage']['uploads'];// Fluent, fully parameterized
$this->table()->where('active', 1)->orderBy('name')->paginate(15, $page);
// Write guards — throws LogicException without where()
$this->table()->where('id', $id)->update(['status' => 'active']);
$this->table()->where('id', $id)->delete();Models return hydrated object instances instead of raw arrays when using finding helpers:
// Find record by primary key ID and return hydrated Model instance
$user = (new User)->findInstance(1);
echo $user->name;
// Find record by any unique column (e.g. slug) and return hydrated Model instance
$post = (new Post)->findInstanceBy('slug', 'my-first-post');
echo $post->title;class User extends Model
{
protected string $table = 'users';
public function orders(): RelationQuery
{
return $this->hasMany(Order::class, foreignKey: 'user_id');
}
}
// Single record
$user = (new User)->find(1);
$orders = (new User)->orders()->for($user);
// Eager load — 2 queries total, no N+1
$users = (new User)->all();
$users = (new User)->orders()->loadFor($users, as: 'orders');// Register — async/sync per listener
$app->events->listen('order.placed', UpdateInventory::class); // sync
$app->events->listen('order.placed', SendOrderSms::class,
async: true, maxAttempts: 3, onFailure: 'retry' // async
);
// Dispatch — identical regardless
$this->event('order.placed', $order);$v = $this->validate($this->request->getBody(), [
'email' => 'required|email|unique:users,email',
'password' => 'required|min:8|max:64',
'phone' => 'nullable|string|regex:/^\+?[0-9]{7,15}$/',
]);
if ($v->fails()) {
return $this->render('register', ['errors' => $v->errors()]);
}use App\Core\Application;
// Log informative message with placeholder injection
Application::$app->logger->info("User {username} performed an action", [
'username' => 'john_doe'
]);
// Logs go to: storage/logs/app-YYYY-MM-DD.log
// Uncaught exceptions are automatically logged with traces by ExceptionHandler.The QueryBuilder automatically compiles queries with quotes appropriate to the active driver:
- MySQL: Compiles table/column names using backticks:
SELECT `id`, `name` FROM `users` WHERE `active` = 1
- SQLite: Compiles table/column names using double quotes:
SELECT "id", "name" FROM "users" WHERE "active" = 1
Encapsulate your validation and authorization logic into dedicated Request objects:
namespace App\Controllers\Requests;
use App\Core\FormRequest;
class StorePostRequest extends FormRequest
{
public function authorize(): bool
{
return $this->session->get('role') === 'admin';
}
public function rules(): array
{
return [
'title' => 'required|string|min:5|max:100',
'body' => 'required|string',
];
}
}Type-hint the FormRequest in your controller action, and the Router will automatically validate and inject it:
public function store(StorePostRequest $request)
{
// Execution only reaches here if validation and authorization pass.
$validatedData = $request->getBody();
(new Post)->create($validatedData);
return $this->redirect('/posts');
}| Threat | Mitigation |
|---|---|
| SQL Injection | QueryBuilder — fully parameterized, zero raw SQL |
| XSS | $this->escape() in all views — ENT_QUOTES UTF-8 |
| CSRF | Token validated on all POST (form / AJAX header / JSON body) |
| Session Fixation | Session::regenerate() after every login |
| Path Traversal | Regex + realpath() double-guard in View |
| Open Redirect | Response::redirect() blocks external domains |
| Clickjacking | SecurityHeadersMiddleware — X-Frame-Options: SAMEORIGIN |
| MIME Sniffing | X-Content-Type-Options: nosniff |
The .cursorrules file encodes the full architecture as enforceable rules for AI assistants (Cursor, GitHub Copilot, etc.). It covers:
- Directory structure and namespace conventions
- Security rules (mandatory escape, CSRF in every form, session regeneration)
- QueryBuilder API reference
- Relationship patterns and eager loading rules
- Async queue configuration
- Feature workflow (route → service → model → controller → view)
- No magic — every class is explicit and traceable
- No raw SQL — QueryBuilder only, write guards enforced
- No inline side effects — SMS/email/PDF goes through Listeners
- No hardcoded config — everything via
.env - Explicit over implicit —
foreignKeyis always declared, never guessed
MIT