vancil / flint
Flint — fast, expressive PHP for production APIs
Requires
- php: >=8.1
- vlucas/phpdotenv: ^5.5
Requires (Dev)
- phpunit/phpunit: ^10.0
This package is auto-updated.
Last update: 2026-05-31 04:21:48 UTC
README
A lightweight, fast PHP framework built for the web. Laravel-style ergonomics, session-based auth, a Blade-like template engine, and a fraction of the overhead. Expressive routing, Active Record ORM, queue jobs, schema builder, and a CLI — all with zero magic.
PHP >= 8.1 required.
Philosophy
Flint is built around three ideas:
Slim. Core ships with only what every application needs — routing, ORM, validation, queues, sessions, templates, and a CLI. Nothing more. Auth scaffolding, mail, and third-party integrations are available as installable packages so you only carry what you actually use.
Fast. The entire framework boots in a single Application::boot() call. No deferred service providers, no lazy container chains, no runtime class generation. What you see is what runs.
Auditable. No facades, no static proxies, no magic. Every dependency is injected directly and every call can be followed in a debugger. The full framework source is small enough to read in an afternoon.
Packages
Features that don't belong in core are available as official Vancil packages:
| Package | Description |
|---|---|
vancil/flint-auth |
Auth scaffold — Bootstrap, Vue, and React presets with login, register, password reset, and email verification |
vancil/flint-mail |
Mail package — Mailable classes, async queueing, and six drivers (SMTP, Mailgun, Postmark, SES, SendGrid, Log) |
Install any package with Composer and register it in your application — no configuration files to publish, no service providers to remember.
Getting Started
composer create-project vancil/flint my-app cd my-app cp .env.example .env # edit .env with your database credentials php flint key:generate php flint migrate php -S localhost:8000 -t public
To scaffold auth pages and views immediately:
composer require vancil/flint-auth php flint ui bootstrap --auth php flint migrate
Directory Structure
├── app/
│ ├── Controllers/
│ ├── Models/
│ └── Jobs/
├── config/
├── core/ # Framework internals (do not edit)
├── database/
│ └── migrations/
├── public/
│ └── index.php # Single entry point
├── resources/
│ ├── views/ # Spark template files (.spark.php)
│ └── js/ # Frontend JS (Vue/React)
├── routes/
│ └── web.php
├── storage/
│ └── views/ # Compiled view cache (git-ignored)
└── flint # CLI
Configuration
Copy .env.example to .env and fill in your values:
APP_NAME=Flint APP_ENV=local APP_DEBUG=true APP_SECRET=change-me-in-production APP_URL=http://localhost:8000 DB_DRIVER=mysql DB_HOST=127.0.0.1 DB_PORT=3306 DB_DATABASE=flint DB_USERNAME=root DB_PASSWORD= SESSION_COOKIE=flint_session SESSION_LIFETIME=7200 MAIL_DRIVER=log MAIL_FROM_ADDRESS=hello@example.com MAIL_FROM_NAME="Flint" QUEUE_DRIVER=database
Config files live in config/. Access values anywhere using dot notation:
config('app.name'); // "Flint" config('database.driver'); // "mysql" config('app.debug'); // true
Spark Template Engine
Flint ships with Spark, a Blade-like template engine. View files use the .spark.php extension and live in resources/views/.
Rendering a View
return Response::view('home', ['user' => $user]);
Syntax
Escaped output (XSS-safe):
{{ $name }}
Raw output:
{!! $html !!}
Control structures:
@if ($user->isAdmin())
<p>Admin panel</p>
@elseif ($user->isEditor())
<p>Editor panel</p>
@else
<p>Welcome</p>
@endif
@foreach ($posts as $post)
<h2>{{ $post->title }}</h2>
@endforeach
Auth directives:
@auth
<a href="/dashboard">Dashboard</a>
@endauth
@guest
<a href="/login">Login</a>
@endguest
Forms:
<form method="POST" action="/login"> @csrf <input type="email" name="email" value="@old('email')"> @error('email') <p class="error">{{ $message }}</p> @enderror <button type="submit">Login</button> </form>
Layouts:
resources/views/layouts/app.spark.php:
<!DOCTYPE html> <html> <head><title>{{ $title ?? 'Flint' }}</title></head> <body> @yield('content') </body> </html>
resources/views/home.spark.php:
@extends('layouts.app')
@section('content')
<h1>Hello, {{ $name }}!</h1>
@endsection
Partials:
@include('partials.nav')
@include('partials.alert', ['type' => 'success', 'message' => 'Saved'])
CLI
php flint make:view auth.login # resources/views/auth/login.spark.php php flint make:layout app # resources/views/layouts/app.spark.php php flint view:clear # delete compiled cache files
Sessions
Sessions start automatically on every request (via global SessionMiddleware). Use the session() helper or inject Flint\Session directly:
session()->set('key', 'value'); session()->get('key', 'default'); session()->has('key'); session()->forget('key'); session()->flash('status', 'Saved successfully!');
Old input is available in views after a failed form submission:
old('email'); // PHP @old('email') // Spark directive
CSRF Protection
All POST, PUT, PATCH, and DELETE requests are CSRF-verified automatically. Include the token in every form:
<form method="POST" action="/profile"> @csrf ... </form>
For AJAX requests, send the token as a header:
fetch('/api/data', { method: 'POST', headers: { 'X-CSRF-Token': await fetch('/api/csrf-token').then(r => r.json()).then(d => d.token) }, })
API routes under /api/* are excluded from CSRF verification by default. Customise via config/csrf.php:
return [ 'except' => ['/api/*', '/webhooks/*'], ];
Auth
The Flint\Auth\Auth class provides session-based authentication. It is available via constructor injection:
use Flint\Auth\Auth; class DashboardController { public function __construct(private readonly Auth $auth) {} public function index(): Response { return Response::view('dashboard', ['user' => $this->auth->user()]); } }
| Method | Description |
|---|---|
$auth->user() |
Authenticated user object, or null |
$auth->check() |
true if logged in |
$auth->id() |
Authenticated user's ID |
$auth->guest() |
true if not logged in |
$auth->login($user, $remember) |
Log in; optionally set a 30-day remember-me cookie |
$auth->logout() |
End session and clear remember-me cookie |
Protect routes with the auth middleware alias:
$router->group(['middleware' => ['auth']], function ($router) { $router->get('/dashboard', [DashboardController::class, 'index']); });
To scaffold full auth pages (login, register, forgot password, email verification), install vancil/flint-auth.
Routing
Define routes in routes/web.php. The $router variable is available automatically.
$router->get('/users', [UserController::class, 'index']); $router->post('/users', [UserController::class, 'store']); $router->put('/users/{id}', [UserController::class, 'update']); $router->delete('/users/{id}', [UserController::class, 'destroy']); // Closure routes $router->get('/', fn() => Response::view('home'));
Route Groups
$router->group(['prefix' => '/api', 'middleware' => ['auth']], function ($router) { $router->get('/profile', [ProfileController::class, 'show']); $router->put('/profile', [ProfileController::class, 'update']); });
Route Parameters
// Route: /users/{id} public function show(int $id): Response { ... } // Route: /posts/{slug} public function show(string $slug): Response { ... }
Controllers
php flint make:controller User
Controllers are plain classes in app/Controllers/. Dependencies are resolved via constructor injection.
namespace App\Controllers; use Flint\Request; use Flint\Response; use App\Models\User; class UserController { public function index(): Response { return Response::view('users.index', ['users' => User::all()]); } public function store(Request $request): Response { $data = $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email', ]); User::create($data); return Response::redirect('/users'); } }
Request
$request->method(); // "GET", "POST", etc. $request->uri(); // "/users/42" $request->input('name'); // single value from GET/POST/JSON $request->input('role', 'user'); // with default $request->all(); // all input merged $request->has('email'); // bool $request->header('Authorization'); // raw header value $request->bearerToken(); // strips "Bearer " prefix $request->isJson(); // checks Content-Type $request->file('avatar'); // $_FILES entry $request->ip(); // client IP
Validation
$data = $request->validate([ 'name' => 'required|string|max:255', 'email' => 'required|email', 'password' => 'required|min:8', 'role' => 'required|in:admin,editor,user', 'score' => 'nullable|numeric|min:0', ]);
On failure in a web (non-JSON) request, the user is redirected back with errors and old input automatically. In a JSON request, a 422 response is returned.
Available rules:
| Rule | Description |
|---|---|
required |
Must be present and non-empty |
string |
Must be a string |
int / integer |
Must be an integer |
numeric |
Must be numeric |
email |
Must be a valid email |
min:N |
Length or value >= N |
max:N |
Length or value <= N |
in:a,b,c |
Must be one of the listed values |
nullable |
Allow null/missing (skips remaining rules) |
confirmed |
Must match {field}_confirmation |
unique:table,column |
Must not already exist in the database |
Response
Response::view('home', ['name' => 'Dan']); // render Spark view Response::json($data, 200); // application/json Response::html('<h1>Hello</h1>'); // text/html Response::text('plain text'); // text/plain Response::redirect('/login', 302); // redirect Response::back(); // redirect to previous URL Response::noContent(); // 204 // Web redirect helpers (chainable) return Response::back() ->withErrors(['email' => ['Invalid credentials.']]) ->withInput(['email' => $data['email']]); // Header chaining return Response::json(['id' => 1]) ->withHeader('X-Custom', 'value') ->withStatus(201);
Send email via the Flint\Mail\Mailer (injected via constructor):
use Flint\Mail\Mailer; class WelcomeController { public function __construct(private readonly Mailer $mailer) {} public function store(Request $request): Response { // ... create user ... $this->mailer ->to($user->email, $user->name) ->subject('Welcome to ' . config('app.name')) ->view('emails.welcome', ['user' => $user]) ->send(); return Response::redirect('/dashboard'); } }
Set the driver in .env:
MAIL_DRIVER=log # writes to storage/logs/mail.log (default) MAIL_DRIVER=smtp # sends real email
Models & ORM
php flint make:model Post
namespace App\Models; use Flint\Model; class Post extends Model { protected string $table = 'posts'; protected array $fillable = ['title', 'body', 'user_id']; protected array $casts = ['published' => 'bool']; }
Querying
Post::all(); Post::find(1); Post::findOrFail(1); Post::where('published', true)->get(); Post::where('score', '>', 4.5)->orderBy('created_at', 'DESC')->limit(10)->get(); Post::where('slug', $slug)->firstModel();
Creating & Updating
$post = Post::create(['title' => 'Hello', 'body' => '...']); $post->update(['title' => 'Updated']); $post->title = 'Also works'; $post->save(); $post->delete();
Migrations
php flint make:migration create_posts_table
use Flint\Schema; use Flint\Blueprint; return new class { public function up(): void { Schema::create('posts', function (Blueprint $table) { $table->id(); $table->integer('user_id')->unsigned(); $table->string('title'); $table->string('slug')->unique(); $table->longText('body')->nullable(); $table->boolean('published')->default(false); $table->datetime('published_at')->nullable(); $table->timestamps(); }); } public function down(): void { Schema::dropIfExists('posts'); } };
php flint migrate php flint migrate:rollback
Schema Builder Reference
| Method | MySQL type |
|---|---|
id() |
BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY |
string('col', 255) |
VARCHAR(255) |
text('col') |
TEXT |
longText('col') |
LONGTEXT |
integer('col') |
INT |
bigInteger('col') |
BIGINT |
boolean('col') |
TINYINT(1) |
float('col') |
FLOAT |
decimal('col', 8, 2) |
DECIMAL(8,2) |
datetime('col') |
DATETIME |
timestamp('col') |
TIMESTAMP |
json('col') |
JSON |
timestamps() |
Adds created_at + updated_at |
softDeletes() |
Adds deleted_at |
Modifiers: ->nullable(), ->default($value), ->unique(), ->unsigned()
Middleware
Built-in aliases registered automatically:
| Alias | Behaviour |
|---|---|
cors |
CORS headers + OPTIONS preflight (204) |
session |
Start session (applied globally) |
csrf |
Verify CSRF token on state-changing requests (applied globally) |
auth |
Redirect to /login if unauthenticated |
Apply per-route:
$router->group(['middleware' => ['auth']], function ($router) { $router->get('/dashboard', [DashboardController::class, 'index']); });
Cache
Configure the driver in config/cache.php or via .env:
CACHE_DRIVER=file # file (default), redis, array CACHE_TTL=3600 # default TTL in seconds CACHE_PREFIX=flint_
Use the cache() helper or inject Flint\Cache\Cache directly:
cache()->put('key', $value); // store with default TTL cache()->put('key', $value, 60); // store for 60 seconds cache()->forever('key', $value); // store indefinitely cache()->get('key'); // retrieve, null if missing cache()->get('key', 'default'); // retrieve with fallback cache()->has('key'); // true/false cache()->forget('key'); // remove one entry cache()->flush(); // remove all entries cache()->pull('key'); // get and immediately remove
remember retrieves a cached value or computes and stores it on a miss:
$user = cache()->remember('user.' . $id, 300, function () use ($id) { return User::find($id); }); $settings = cache()->rememberForever('settings', fn() => Settings::all());
Drivers
| Driver | Description |
|---|---|
file |
Serialized files in storage/cache/ — no extra dependencies |
redis |
Uses the php-redis extension; shares Redis config with the queue |
array |
In-memory only, no persistence — useful for testing |
php flint cache:clear # flush all cached values
Queue
php flint make:job SendWelcomeEmail
namespace App\Jobs; use Flint\Queue\Job; class SendWelcomeEmail extends Job { public int $tries = 3; public function __construct( private readonly int $userId, private readonly string $email, ) {} public function handle(): void { /* ... */ } public function failed(\Throwable $e): void { /* ... */ } }
use Flint\Queue\Queue; Queue::dispatch(new SendWelcomeEmail($user->id, $user->email)); Queue::later(300, new SendWelcomeEmail($user->id, $user->email));
php flint queue:work php flint queue:work --queue=emails
CLI Reference
php flint key:generate # generate APP_SECRET and write to .env php flint make:controller <Name> # app/Controllers/NameController.php php flint make:model <Name> # app/Models/Name.php php flint make:job <Name> # app/Jobs/NameJob.php php flint make:migration <name> # database/migrations/<timestamp>_name.php php flint make:view <name> # resources/views/<name>.spark.php php flint make:layout <name> # resources/views/layouts/<name>.spark.php php flint migrate # run pending migrations php flint migrate:rollback # roll back last batch php flint queue:work # start queue worker php flint cache:clear # flush all cached values php flint view:clear # clear compiled Spark view cache
Error Handling
| Situation | Response |
|---|---|
ValidationException in JSON request |
422 with { "errors": { ... } } |
ValidationException in web request |
Redirect back with errors + old input |
ModelNotFoundException |
404 with error message |
| CSRF mismatch | 419 plain text |
Any other Throwable |
500 — full trace if APP_DEBUG=true, generic message if false |