Savv Web Framework
A zero-config, zero-build PHP engine for high-performance brand websites, portfolios, and public-facing web experiences. The speed of a static site, the power of a lean PHP core — without the build-tool tax.
The entire framework package is ~638 KB as of v2.1.0. Less than 1 MB total. You can trace the entire codebase in an afternoon.
Why Savv Web #
Modern PHP frameworks are powerful — but they carry enormous overhead when all you need is a clean, fast, presentation-first website. Savv Web was built to fill that gap without compromise.
| Feature | Savv Web | Full-Stack PHP | SSG |
|---|---|---|---|
| File-based routing — zero config | ✓ | ✗ | ✓ |
| No build tool required | ✓ | ✗ | ✗ |
| PWA built-in, no setup | ✓ | ✗ | ✗ |
| URL redirections, no settings | ✓ | ✗ | ✗ |
| Edit live in production | ✓ | ✗ | ✗ |
| Deploy on shared/budget hosting | ✓ | ⚠ | ✓ |
| CMS fallback support | ✓ | ✗ | ✗ |
Package Model #
Savv Web is split into two repositories to keep responsibilities clean.
Savv\. All source in
src/. Contains the bootstrapper, Router, Request, Response, Config, Validator,
Log, SystemController (PWA), Console Kernel, and all helpers. Install via Composer.
Quick Start #
Option A — Clone the starter (recommended)
git clone https://github.com/igefadele/savv_starter my-project cd my-project composer install
Point your server document root to public/ and open the browser.
Option B — Install the framework directly
composer require savadub/savv
Via GitHub VCS
{
"repositories": [{
"type": "vcs",
"url": "https://github.com/igefadele/savv_framework"
}],
"require": {
"savadub/savv": "dev-main"
}
}
Project Structure #
Bootstrap Flow #
define('ROOT_PATH', dirname(__DIR__)); require ROOT_PATH . '/vendor/autoload.php'; $app = \Savv\Core\Application::bootstrap(__DIR__); $app->run();
Application::run() executes this sequence on every request:
- Route cache check — looks for
storage/framework/routes.php - Cache hit → loads routes via
Router::loadRawRoutes()(fast path) - Cache miss →
loadRouteFiles()loads internal framework routes (PWA), thenroutes/*.php, thenregisterRedirections() - Request capture —
Request::capture()snapshots superglobals - Dispatch —
Router::dispatch()matches explicit routes - Dynamic discovery —
resolveDynamicView()scansviews/pages/for GET requests - CMS fallback —
handleExternalFallbacks()checksconfigs/installations.php - 404 — renders
views/404.phpor a plain string
Routing #
1. File-Based Routing — Zero Configuration
The most common way to add a page. Place any .php file in views/pages/ and
it resolves as a URL with zero other steps:
views/pages/index.php → GET / views/pages/about.php → GET /about views/pages/services.php → GET /services views/pages/blog/post.php → GET /blog/post
Page parts (partials, sections, components) can live anywhere in views/. Just make
sure the main page file is in views/pages/ and imports them correctly.
2. Explicit Web Routes
router()->get('/', function () { require ROOT_PATH . '/views/pages/index.php'; }); router()->get('blog/{slug}', function ($slug) { require ROOT_PATH . '/views/pages/blog.php'; })->name('blog.show'); // Using the view() method router()->get('about', function () { return router()->view('pages/about'); });
3. API Routes
use App\Controllers\ContactController; router()->group(['prefix' => 'api', 'name' => 'api.'], function($router) { $router->post('contact-submit', [ContactController::class, 'submit']) ->name('submit.contact'); });
4. Named Routes & Parameters
// Generate URL from route name $url = route('blog.show', ['slug' => 'getting-started']); // → /blog/getting-started
5. Route Caching
Compile all routes into a static manifest for production performance:
php savv route:cache
# → storage/framework/routes.php generated
Delete storage/framework/routes.php to return to dynamic mode. Regenerate after
adding new pages or changing routes.
Views & Layouts #
$pageTitle = 'About — My Brand'; $pageDescription = 'Who we are and what we build.'; ob_start(); ?> <section> <h1>About Us</h1> <p>Our story here.</p> </section> <?php $content = ob_get_clean(); include ROOT_PATH . '/views/layouts/index.php';
Path helpers are also available:
view_path('pages/about.php'); // → /root/views/pages/about.php page_path('about.php'); // → /root/views/pages/about.php post_path('how-to-savv.md'); // → /root/views/posts/how-to-savv.md
The #savv ID
If you are not using the Savv Starter, wrap your main element with
id="savv" for SPA-feel navigation and fast page transitions to work. The Starter
does this for you in views/layouts/index.php.
<main id="savv" class="transition-fade"> <?php echo $content; ?> </main>
Blogging #
Any .md file placed in views/posts/ becomes a blog post accessible at
/{slug}. Each file must start with frontmatter:
--- title: My First Post slug: my-first-post date: 2026-04-17 author: Your Name status: published category: general --- # Post Content Write in Markdown here.
Only posts with status: published are publicly accessible. Register slugs in
configs/posts.php:
return [ 'my-first-post' => 'My First Post', ];
Use views/pages/posts.php for the blog listing and
views/pages/post-detail.php for individual posts.
Middleware #
Define aliases in configs/middlewares.php, then apply them to routes or groups.
return [ 'auth' => \App\Middleware\Authenticate::class, ];
// Apply to a group router()->group(['prefix' => 'dashboard', 'middleware' => 'auth'], function ($r) { $r->get('overview', function() { require ROOT_PATH . '/views/pages/dashboard.php'; }); }); // Apply to a single route router()->post('contact', [ContactController::class, 'submit']) ->middleware('auth')->name('submit.contact');
Writing a middleware
namespace App\Middleware; use Savv\Utils\Request; class Authenticate { public function handle(Request $request, callable $next) { if (!isset($_SESSION['user_id'])) { return response()->redirect('/login'); } return $next($request); } }
PWA — Built In, No Action Required #
The framework self-registers two routes on every boot:
GET /manifest.json → SystemController::getManifestFile() GET /sw.js → SystemController::getServiceWorkerFile()
Configure everything in configs/pwa.php:
return [ 'name' => 'My Brand', 'short_name' => 'Brand', 'version' => 'v1', // Bump to bust the SW cache 'theme_color' => '#081065', 'background_color' => '#ffffff', 'display' => 'standalone', 'icons' => [ ['src' => '/assets/images/icons/icon-192x192.png', 'sizes' => '192x192'], ['src' => '/assets/images/icons/icon-512x512.png', 'sizes' => '512x512'], ], 'precache' => ['/', '/offline', '/assets/css/main.css'], ];
That is the only step. The manifest, service worker, and offline fallback are all handled by the framework.
Layout Helpers #
savv_head()
Call inside <head>. Injects: PWA manifest link, theme-color meta, Apple touch
icon, Bootstrap 5 CSS, Bootstrap Icons CSS, and AOS CSS.
<head>
<meta charset="UTF-8">
<title><?= $pageTitle ?></title>
<?php savv_head(); ?>
</head>
savv_scripts()
Call before </body>. Injects: Bootstrap 5 JS, AOS (auto-initialized), Swup with
HeadPlugin and ScrollPlugin, PWA service worker registration, counter animations, and the
savv:init event dispatcher.
<?php savv_scripts(); ?> <script src="/assets/js/main.js"></script> </body>
The savv:init Event #
Savv dispatches a custom browser event called savv:init every time a page is initialized
— on initial load and after every Swup page transition.
savv:init = "The page is ready. Run your UI logic now."
Use it instead of
DOMContentLoaded for any DOM-dependent code.
// Runs on initial load AND after every page swap const myAppLogic = () => { // Initialize sliders, counters, tooltips, etc. const counters = document.querySelectorAll('.counter-element'); // ... your component logic }; document.addEventListener('savv:init', myAppLogic);
| Event | Fires When | Use Case |
|---|---|---|
| DOMContentLoaded | Once on initial load | Traditional multi-page sites |
| savv:init | Every load + every page swap | Savv-powered applications |
Global Helper Functions #
All helpers are auto-loaded via src/Helpers/helpers.php.
request()
request() // Request singleton request('name', 'Guest') // input with default request()->only(['name', 'email']) // subset request()->all() // merged POST + GET request()->post('field') // POST only request()->query('page') // GET only request()->method() // 'GET', 'POST', etc. request()->path() // '/about' request()->filled('email') // bool — '0' counts as filled request()->ajax() // bool — X-Requested-With check
response()
response('<h1>Hello</h1>', 200) response()->json(['status' => 'success'], 201) response()->redirect('/thank-you') response()->redirect('/new-url', 301) response()->header('X-Powered-By', 'Savv') response()->view('pages/about', ['title' => 'About'])
config()
config('mail.smtp.host') config('pwa.theme_color') config('pwa.version') config('redirections.facebook')
validate()
Validates and terminates with 422 JSON on failure. Returns only declared keys on
success.
$validated = validate(request()->all(), [ 'name' => 'required', 'email' => 'required|email', 'message' => 'required|min:10|max:2000', 'budget' => 'numeric', 'website' => 'url', ]); // Rules: required, email, min:n, max:n, numeric, url
logger()
logger('Form submitted', ['email' => request('email')]); // info logger()->error('Mail failed', ['reason' => $e->getMessage()]); logger()->warning('Slow query', ['ms' => 850]); logger()->debug('Route matched', ['path' => request()->path()]); // Writes to: storage/logs/YYYY-MM-DD.log // [2026-04-17 14:30:01] INFO: Form submitted {"email":"..."}
Core Utility Classes #
Savv\Utils\Request
| Method | Description |
|---|---|
| capture() | Static factory — builds instance from superglobals |
| input($key, $default) | POST precedence over GET |
| all() | Merged GET + POST |
| post($key, $default) | POST data only |
| only(array $keys) | Subset of inputs |
| except(array $keys) | All inputs minus excluded keys |
| filled($key) | Non-empty check — '0' and 0 count as filled |
| query($key, $default) | Query string values |
| method() | HTTP method string |
| path() | Request path without query string |
| ajax() | Detects X-Requested-With: XMLHttpRequest |
Savv\Utils\Response
| Method | Description |
|---|---|
| setStatus(int $code) | Set HTTP status code |
| header($key, $value) | Add a response header |
| json(array $data, int $status) | JSON response with correct Content-Type |
| redirect(string $url, int $status) | HTTP redirect — default 302 |
| view(string $viewPath, array $data) | Render a PHP view into the content buffer |
| send() | Output status code, headers, and body |
Savv\Utils\Log
Static methods: info(), error(), warning(),
debug(). Writes to storage/logs/YYYY-MM-DD.log. Directory is created
automatically. Uses FILE_APPEND | LOCK_EX for safe concurrent writes.
Savv\Utils\Router
Singleton. Supports GET, POST, named routes, route parameters ({slug}), route groups
with prefix/name/middleware inheritance, middleware pipeline, dynamic view discovery, and
serializable route cache.
URL Redirections #
return [ 'fb' => 'https://facebook.com/yourpage', // 302 'careers' => ['url' => 'https://jobs.example.com', 'status' => 301], ];
yourdomain.com/fb redirects automatically. No controller. No route file edit. Registered
at bootstrap by Router::registerRedirections().
External CMS Fallback #
Run WordPress, WooCommerce, or any PHP app alongside Savv under the same domain — no server rewrites needed.
return [ 'wordpress' => [ 'active' => true, 'path' => '/var/www/wordpress/wp-blog-header.php', ], 'ecommerce' => [ 'active' => false, 'path' => '/var/www/shop/index.php', ], ];
When no Savv route matches, handleExternalFallbacks() iterates this config and hands off
to the first active installation. Custom 404 views are supported at views/404.php.
CLI Commands #
php savv <command> [arguments]
| Command | Description |
|---|---|
| route:cache | Compiles all routes into storage/framework/routes.php |
| make:config <name> | Scaffolds a blank config file in configs/ |
| make:controller <name> | Scaffolds a controller class in app/Controllers/ |
route:cache compiles: explicit routes, file-based view routes from
views/pages/, redirections, and posts. Delete the cache file to return to dynamic
mode.
Configuration Reference #
configs/pwa.php
return [ 'name' => 'My App', 'short_name' => 'App', 'version' => 'v1', // bump to bust SW cache 'theme_color' => '#000000', 'background_color' => '#ffffff', 'display' => 'standalone', 'icons' => [...], 'precache' => ['/', '/offline', '/assets/css/main.css'], ];
configs/mail.php
return [ 'smtp' => [ 'host' => $_ENV['SMTP_HOST'] ?? null, 'port' => $_ENV['SMTP_PORT'] ?? null, 'user' => $_ENV['SMTP_USER'] ?? null, 'password' => $_ENV['SMTP_PASSWORD'] ?? null, 'security' => 'tls', 'from' => $_ENV['SMTP_FROM'] ?? null, 'to' => $_ENV['SMTP_TO'] ?? null, ], ];
Deployment #
- Point your server document root to
public/ - Keep all application files above the public web root
- Ensure
storage/logs/andstorage/framework/are writable by the web server - Provide all required values in
.envfor production - Run
php savv route:cachebefore going live - Server block samples for Apache, Nginx, Caddy, and LiteSpeed are in the starter
at
public/server-block-samples/
Autoloading #
{
"autoload": {
"psr-4": { "App\\": "app/" },
"files": [ "app/helpers.php" ]
}
}
composer dump-autoload
Philosophy #
Most websites do not need a full-stack framework. They need clean routing, a request/response model, config management, validation, and a sensible structure — and they need to be fast, deployable anywhere, and editable without a build pipeline.
Savv Web delivers exactly that. Nothing more, nothing less. It feels familiar to developers coming from Laravel conventions while remaining readable enough that someone new to frameworks can trace the entire codebase in an afternoon.
Savv Web is for developers who value readability, directness, and control — without the ceremony.