v2.4.1 GitHub Get Starter Project
v2.4.1 · ~638 KB · MIT

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.

Framework savadub/savv
The installable core package. Namespace: Savv\. All source in src/. Contains the bootstrapper, Router, Request, Response, Config, Validator, Log, SystemController (PWA), Console Kernel, and all helpers. Install via Composer.
Starter savv_starter
The ready-to-use project skeleton. Already depends on the framework. Provides the application structure, configs, layouts, views, partials, and example pages. Recommended starting point.

Quick Start #

Option A — Clone the starter (recommended)

terminal
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

terminal
composer require savadub/savv

Via GitHub VCS

composer.json
{
  "repositories": [{
    "type": "vcs",
    "url": "https://github.com/igefadele/savv_framework"
  }],
  "require": {
    "savadub/savv": "dev-main"
  }
}

Project Structure #

my-savv-app/ ├── app/ │ ├── Controllers/ │ └── Middleware/ ├── configs/ │ ├── mail.php │ ├── middlewares.php │ ├── posts.php │ ├── pwa.php │ ├── redirections.php │ └── installations.php # CMS/app integrations ├── public/ # ← web server document root │ └── index.php # 3-line entry point ├── routes/ │ ├── web.php │ └── api.php ├── storage/ │ ├── framework/ │ │ └── routes.php # generated route cache │ └── logs/ ├── views/ │ ├── layouts/ │ │ └── index.php │ ├── pages/ # file-based routing root │ │ ├── index.php # → / │ │ ├── about.php # → /about │ │ └── offline.php # → /offline (PWA) │ ├── partials/ │ └── posts/ # markdown post files └── .env

Bootstrap Flow #

public/index.php
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:

  1. Route cache check — looks for storage/framework/routes.php
  2. Cache hit → loads routes via Router::loadRawRoutes() (fast path)
  3. Cache missloadRouteFiles() loads internal framework routes (PWA), then routes/*.php, then registerRedirections()
  4. Request captureRequest::capture() snapshots superglobals
  5. DispatchRouter::dispatch() matches explicit routes
  6. Dynamic discoveryresolveDynamicView() scans views/pages/ for GET requests
  7. CMS fallbackhandleExternalFallbacks() checks configs/installations.php
  8. 404 — renders views/404.php or 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:

file-to-url mapping
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

routes/web.php
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

routes/api.php
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

php
// 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:

terminal
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 #

views/pages/about.php
$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:

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

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:

views/posts/my-post.md
---
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:

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.

configs/middlewares.php
return [
    'auth' => \App\Middleware\Authenticate::class,
];
routes/web.php
// 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

app/Middleware/Authenticate.php
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:

auto-registered routes
GET /manifest.json  →  SystemController::getManifestFile()
GET /sw.js          →  SystemController::getServiceWorkerFile()

Configure everything in configs/pwa.php:

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.

views/partials/head.php
<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.

views/partials/scripts.php
    <?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.

assets/js/main.js
// 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()

php
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()

php
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()

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

php
$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()

php
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 #

configs/redirections.php
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.

configs/installations.php
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 #

terminal
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

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

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/ and storage/framework/ are writable by the web server
  • Provide all required values in .env for production
  • Run php savv route:cache before going live
  • Server block samples for Apache, Nginx, Caddy, and LiteSpeed are in the starter at public/server-block-samples/

Autoloading #

composer.json
{
  "autoload": {
    "psr-4": { "App\\": "app/" },
    "files": [ "app/helpers.php" ]
  }
}
terminal
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.