Features & API
Every framework call you'll need, with copy-paste-ready syntax. Bookmark this page.
AI agents (MCP / REST / CLI)
Make your app callable by AI agents. Define a capability (a "skill") once and the framework exposes it over MCP (/mcp), REST (/agent/v1) and the CLI at the same time. The framework ships the transports, auth, validation and audit; your capability owns only its logic. Inert until AI_ENABLED=true.
// app/Ai/CatalogSearch.php - define once
namespace App\Ai;
use BC\Core\Ai\{AbstractCapability, AgentContext};
final class CatalogSearch extends AbstractCapability {
public function name(): string { return 'catalog.search'; }
public function description(): string { return 'Search products by keyword.'; }
public function inputSchema(): array {
return ['type' => 'object',
'properties' => ['q' => ['type' => 'string']],
'required' => ['q']];
}
// public function readOnly(): bool { return false; } // writes need an authed agent
public function run(array $args, AgentContext $ctx): mixed {
return $this->c->db()->all(
"SELECT id, title, price FROM products WHERE title LIKE :q LIMIT 20",
['q' => '%' . $args['q'] . '%']
);
}
}
// config/config.php - register it
'ai' => [
'enabled' => Env::get('AI_ENABLED', false),
'capabilities' => [ \App\Ai\CatalogSearch::class ],
'agents' => [ 'my-bot' => Env::get('AI_AGENT_TOKEN', '') ], // gates writes
],
That one class is now reachable three ways:
# MCP (Claude, ChatGPT connectors, Perplexity, Gemini, …)
curl -X POST https://site/mcp -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"tools/call",
"params":{"name":"catalog.search","arguments":{"q":"red shoes"}}}'
# REST (any HTTP bot)
curl -X POST https://site/agent/v1/catalog.search \
-H 'Content-Type: application/json' -d '{"q":"red shoes"}'
# CLI (terminal / shell agents)
bin/bc ai:call catalog.search '{"q":"red shoes"}'
bin/bc ai:list
Agents discover the site automatically:
GET /llms.txt # plain-text index of capabilities
GET /.well-known/agent.json # JSON manifest: endpoints, auth, capability list
GET /agent/v1 # live capability catalog with input schemas
Mutating capabilities (readOnly() returns false) require Authorization: Bearer <token>; reads are open for discovery. Every agent call is written to logs/agent.log. No commerce is built in - "AI can shop & checkout" is just capabilities you add in app/Ai/ (e.g. catalog.search, cart.add, checkout.complete), which map cleanly onto the ACP / UCP agentic-commerce shapes and AP2-style payment mandates.
Routing
Convention-based. No route table. The URL maps directly to a class and method.
URL Resolves to
───────────────────────────────── ─────────────────────────────────────────
/ App\Controllers\Home::index()
/about App\Controllers\About::index()
/contact/submit App\Controllers\Contact::submit()
/product/view/id/42 App\Controllers\Product::view()
└─ $this->url->param['id'] = "42"
/api/v1/customer/login App\Api\V1\Customer::login()
/admin/orders/refund/id/9001 App\Admin\Orders::refund()
└─ $this->url->param['id'] = "9001"
If App\Controllers\Foo doesn't exist, the dispatcher falls back to Demo\Controllers\Foo - that's how the bundled demo works. Create the App class and it shadows the demo automatically.
Controllers
Extend the right base for the kind of endpoint:
// Page controller (HTML + layout)
namespace App\Controllers;
final class Home extends \BC\Core\Mvc\Controller {
protected string $title = 'Welcome';
protected string $description = 'Landing page.';
protected string $layout = 'layouts/app'; // optional, this is the default
public function index(): string {
return $this->view->render('index', ['name' => 'World']);
}
}
// JSON API endpoint (no layout, uniform envelope)
namespace App\Api\V1;
final class Ping extends \BC\Core\Mvc\ApiController {
public function index(): string {
return $this->ok(['pong' => true]);
}
}
// Admin (auth-gated by AdminController::boot)
namespace App\Admin;
final class Dashboard extends \BC\Core\Mvc\AdminController {
protected string $layout = 'layouts/admin';
public function index(): string {
return $this->view->render('index');
}
}
Inside any controller you have: $this->c, $this->url, $this->request, $this->response, $this->view, $this->sanitize, $this->csrf.
Request
Immutable value object. Captured once at boot.
$req = $this->request;
$req->method // "GET" | "POST" | …
$req->path // "/contact/submit"
$req->host // "example.com"
$req->https // bool
$req->ajax // true if X-Requested-With: XMLHttpRequest
$req->ip // "1.2.3.4"
$req->userAgent // string
$req->contentType // "application/json; charset=utf-8"
$req->get // array - raw $_GET
$req->post // array - raw $_POST
$req->files // array - raw $_FILES
$req->json // ?array - decoded JSON body, or null
$req->rawBody // string - body text for PUT/POST/DELETE
$req->header('X-Api-Key') // ?string, case-insensitive lookup
URL-routed values (slug-style URLs):
$this->url->param['id'] // for /product/view/id/42 → "42" (string)
$this->url->get['q'] // for ?q=hello → "hello"
$this->url->currentClass // "product"
$this->url->currentFunction // "view"
$this->url->fullPath // "/product/view/id/42"
$this->url->isApi / isAdmin // bool
AJAX / XHR
A request is flagged ajax when it carries the header
X-Requested-With: XMLHttpRequest. On an ajax request a page
controller skips the layout and returns just the view
body - so the same action serves a full page normally and a bare
fragment over XHR, with no extra code.
// app/Controllers/Blog.php - one action, two outputs
public function feed(): string
{
$posts = $this->c->db()->all(
"SELECT id, title, slug FROM posts ORDER BY id DESC LIMIT 10"
);
return $this->view->render('feed', ['posts' => $posts]);
// normal GET → wrapped in the layout (header + footer + shell)
// XHR GET → just the rendered feed.php fragment
}
Client side - drop a fragment straight into the page:
fetch('/blog/feed', {
headers: { 'X-Requested-With': 'XMLHttpRequest' }
})
.then(r => r.text())
.then(html => { document.getElementById('feed').innerHTML = html; });
Branch by hand when an action should return a different partial for XHR:
if ($this->request->ajax) { // or $this->url->ajax
return $this->view->render('partials/rows', ['rows' => $rows]);
}
return $this->view->render('index', ['rows' => $rows]);
For JSON (not HTML fragments) use an API controller and send the CSRF token on writes:
fetch('/api/v1/posts/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Csrf-Token': '<?= csrf_token() ?>', // GET needs none; writes do
},
body: JSON.stringify({ title: 'Hello', slug: 'hello' }),
})
.then(r => r.json())
.then(res => console.log(res.success, res.data, res.message));
The layout-skip lives in Controller::renderPage().
ApiController never wraps a layout, so JSON endpoints are
unaffected by the ajax flag. See CSRF for token rules and
API endpoints for the {success,data,message,statusCode} envelope.
Sanitise input
Never trust $_POST directly. Wrap and use a typed view:
$name = $this->sanitize->wrap($this->request->post['name'] ?? '')->safe;
$email = $this->sanitize->wrap($this->request->post['email'] ?? '')->email;
$age = $this->sanitize->wrap($this->request->post['age'] ?? '')->int;
$active = $this->sanitize->wrap($this->request->post['active'] ?? '')->bool;
$price = $this->sanitize->wrap($this->request->post['price'] ?? '')->decimal;
$slug = $this->sanitize->wrap($this->request->post['slug'] ?? '')->slug;
$url = $this->sanitize->wrap($this->request->post['url'] ?? '')->url;
$ipv4 = $this->sanitize->wrap($this->request->post['ip'] ?? '')->ipv4;
$plain = $this->sanitize->wrap($this->request->post['note'] ?? '')->escaped;
$raw = $this->sanitize->wrap($this->request->post['raw'] ?? '')->raw; // be careful
// Whole-array clean (for forms with many fields):
$clean = $this->sanitize->clean($this->request->post);
$name = ($clean['name'] ?? null)?->safe ?? '';
$email = ($clean['email'] ?? null)?->email ?? '';
Invalid emails return '' - perfect for "is this a real email?" gating. Same for url, ipv4. The url lens rejects every scheme except http and https - javascript:, data:, file:, gopher: all return '', so the value is safe to drop into an href after eAttr().
Escape output (inside templates)
Every variable interpolated into HTML must go through one of these. Pick by context.
<p><?= e($user->name) ?></p>
<a title="<?= eAttr($tip) ?>"> …
<script>var u = <?= eJs($user) ?>;</script>
<a href="/p/<?= eUrl($slug) ?>"> …
Auto-escaping is by helper, not by syntax. Forgetting a helper IS a bug - the linter rule "any echo from request data must use e()" is a good thing to enforce in review.
Database
PDO under the hood, MySQL 8 with utf8mb4, real prepared statements. Connection is lazy - opens on first query.
$db = $this->c->db();
// SELECT one row (object or null):
$user = $db->row(
"SELECT * FROM users WHERE id = :id LIMIT 1",
['id' => 42]
);
echo $user->email;
// SELECT many rows:
$active = $db->all(
"SELECT id, email FROM users WHERE active = :a ORDER BY id DESC",
['a' => 1]
);
foreach ($active as $row) { /* … */ }
// SELECT a single scalar (e.g. COUNT):
$total = $db->value("SELECT COUNT(*) FROM users");
// INSERT - returns last insert id (int):
$id = $db->execute(
"INSERT INTO users (email, name) VALUES (:e, :n)",
['e' => 'a@b.c', 'n' => 'Alice']
);
// UPDATE / DELETE - returns affected row count (int):
$affected = $db->execute(
"UPDATE users SET name = :n WHERE id = :id",
['n' => 'Alicia', 'id' => $id]
);
// Transaction: auto-commit on return, rollback on throw:
$db->transaction(function ($db) {
$db->execute("INSERT INTO orders …", […]);
$db->execute("INSERT INTO order_items …", […]);
});
// Cached row - looks in cache first; one round-trip on miss:
$row = $db->rowCached($sql, $params, $this->c->cache(), 'user:42', ttl: 600);
NEVER concatenate user input into SQL. The framework gives you no concatenation API. Always use named placeholders + a params array.
Models
Thin base on top of Database - single-table CRUD with mass-assignment whitelist.
namespace App\Models;
final class User extends \BC\Core\Mvc\Model {
protected string $table = 'users';
protected string $primaryKey = 'id';
protected array $fillable = ['email', 'name']; // anything else is silently dropped
}
$users = new User();
$row = $users->find(42); // ?object
$rows = $users->where('role', 'admin'); // object[]
$id = $users->create(['email' => 'a@b.c', 'name' => 'A']);
$users->update(42, ['name' => 'Updated']);
$users->delete(42);
Mass-assignment protection: $user->create($_POST) can never set role unless role is in $fillable. Add only fields you actually want users to set.
Cache
Drivers: redis, apcu, file, null. Set via CACHE_DRIVER in .env or cache.driver in config. Auto-fallback to file on init failure.
$cache = $this->c->cache();
$cache->set('user.42', $userRow, ttl: 600); // seconds; 0 = no expiry
$row = $cache->get('user.42'); // mixed or null
$row = $cache->get('user.42', $default); // miss returns $default
$cache->has('user.42'); // bool
$cache->delete('user.42');
$cache->clear(); // wipe this app's prefix
// Atomic counter (Redis/APCu - file emulates):
$hits = $cache->increment('page.hits', 1);
Redis keys are auto-prefixed with cache.prefix (default bc:) so multiple apps can share a Redis instance.
CSRF
Auto-verified on all non-GET requests. Use csrf_field() in forms; for fetch/XHR, set the X-Csrf-Token header.
<!-- In a form template -->
<form method="POST" action="/contact/submit">
<?= csrf_field() ?>
<input name="email">
<button>Send</button>
</form>
// In JavaScript fetch:
fetch('/api/foo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Csrf-Token': '<?= csrf_token() ?>',
},
body: JSON.stringify({ … }),
});
// After login / privilege change - rotate token:
$this->csrf->rotate();
Sessions
Cookie: HttpOnly, SameSite=Lax, Secure on HTTPS, session.use_strict_mode=1.
$_SESSION['flash'] = 'Saved!'; // standard PHP - already started by Boot
// Rotate session ID at privilege transition (login, logout, password change):
\BC\Core\Security\Session::regenerate();
// Logout - fully destroy session + cookie:
\BC\Core\Security\Session::destroy();
Authentication
Session-based. Requires a users table with email, password_hash, role.
$auth = $this->c->auth();
if ($auth->attempt($email, $password)) {
$this->redirect('/dashboard');
}
if ($auth->isLoggedIn()) { … }
if ($auth->hasRole('admin')) { … }
$auth->logout();
// Hashing for user registration:
$hash = \BC\Core\Security\Auth::hash($password);
Password timing is equalised on missing users - username enumeration via timing is blocked.
Views & themes
Plain PHP. Cascade: app/Views/<theme>/<class>/ → app/Views/<theme>/ → fallback themes → demo → core/Views/.
// From a controller:
return $this->view->render('index', ['title' => 'Hi']);
// From inside another template:
<?= view('partials/header') ?>
<?= view('partials/footer') ?>
// Switch theme for the rest of this request (e.g. A/B variant):
$this->c->view->setTheme('b');
// Config-driven theme (.env):
// VIEW_THEME=dev → app/Views/dev/ searched first
// VIEW_DEMO=false → bundled demo hidden completely
Drop a single file at app/Views/default/home/index.php and it shadows just that demo page - everything else still cascades through demo.
Global helpers
Available in every template and controller (no use needed).
e($value) // HTML text escape
eAttr($value) // HTML attribute escape
eJs($value) // JS literal (JSON-encoded, safe)
eUrl($value) // URL segment encode
view('name', $data) // render another view from a template
csrf_field() //
csrf_token() // the token string itself
url('foo/bar') // "/foo/bar" - site-rooted
app() // the Container singleton
Logger
Appends to logs/<channel>.log. Auto-rotates at 100 MB.
$log = $this->c->logger;
$log->debug('only when env=dev …');
$log->info('Order #1234 created');
$log->warning('Slow API response from upstream');
$log->error('Stripe webhook failed: ' . $exception->getMessage());
// Separate file per channel:
$log->info('Login OK', channel: 'auth'); // → logs/auth.log
$log->warning('Slow query', channel: 'db'); // → logs/db.log
Profiler
Append ?_speed=1 on any URL (when your IP is in security.safe_ips) for a timing breakdown.
$p = $this->c->profiler;
$p->mark('users.query');
$rows = $db->all("…");
$p->end('users.query');
// Same name across loops aggregates - total time across N calls:
foreach ($items as $item) {
$p->mark('item.render');
/* … */
$p->end('item.render');
}
AB testing
Declare experiments in config; variants stick per session and feed into the cache key.
// config/config.php
'ab' => [
'hero_copy' => ['original' => 50, 'new' => 50],
'checkout_button' => ['green' => 70, 'orange' => 30],
],
// Use it anywhere:
if ($this->c->ab->variant('hero_copy') === 'new') { /* … */ }
// Force variant via URL - honoured only when IP is in safe_ips:
// /?ab=hero_copy:new
Measuring results in GA4. The bucketer only assigns variants (sticky per session) - it stores no results itself. Instead, the active cohort is fed to GA4 / GTM automatically: whenever an analytics ID is configured, core/Views/partials/seo.php pushes the variants to the dataLayer just before the tag loads. What you do:
- Declare the experiment in
config/config.phpunder'ab'(the block above). That alone buckets every visitor on boot. - Set
GA_IDorGTM_IDin.env(see Google tags). No layout or template edits - the push rides along with the tag. - In GA4, register
ab_variantsas a custom dimension (Admin → Custom definitions). One-time, done in the GA4 UI. - Keep your existing conversion events. GA4 segments them by variant for you, so it does the counting and significance.
Emitted automatically in <head> once a visitor is bucketed and an analytics ID is set - you never write this:
<script>
window.dataLayer = window.dataLayer || [];
window.dataLayer.push({event:'ab_assigned', ab_variants:{"hero_copy":"new"}});
</script>
Core sends the exposure (which variant was shown); defining what counts as a conversion lives in GA4 (step 4). Caveat: assignments are keyed to the PHP session, not the GA client ID, so a returning visitor whose session expired may be re-bucketed and appear under both variants - fine for directional reads, less so for rigorous tests.
Redirects & responses
// 302 redirect + exit:
$this->redirect('/login');
// 301 permanent:
$this->redirect('/new-home', 301);
// API success / error envelope:
return $this->ok($data, 'Saved', 200);
return $this->fail('Validation failed', 422, ['email' => 'required']);
// JSON one-shot from a page controller:
$this->response->json(['ok' => true]);
API endpoints
JSON in, JSON out. Uniform envelope {success, data, message, statusCode}.
namespace App\Api\V1;
final class Customer extends \BC\Core\Mvc\ApiController {
public function login(): string {
$body = $this->request->json; // decoded array or null
if (!is_array($body)) {
return $this->fail('Invalid JSON', 400);
}
$email = $this->c->sanitize->wrap($body['email'] ?? '')->email;
if ($email === '') {
return $this->fail('email required', 422);
}
// …auth…
return $this->ok(['token' => '…'], 'Logged in');
}
}
Reached at POST /api/v1/customer/login with Content-Type: application/json.
Admin
Routes under /admin/*. Gated by AdminController::boot() - requires role = 'admin' in session.
namespace App\Admin;
final class Orders extends \BC\Core\Mvc\AdminController {
public function index(): string {
$rows = $this->c->db()->all("SELECT * FROM orders ORDER BY id DESC");
return $this->view->render('index', ['rows' => $rows]);
}
public function refund(): string {
$id = $this->sanitize->wrap($this->url->param['id'] ?? '')->int;
// …refund logic…
$this->redirect('/admin/orders');
}
}
Config & .env
Two layers. .env for secrets and per-environment values; config/config.php for shape + defaults.
# .env (gitignored; copy from .env.example)
BC_ENV=prod
DB_HOST=db.internal
DB_NAME=myapp
DB_USER=app
DB_PASS=correct-horse-battery
CACHE_DRIVER=redis
REDIS_HOST=redis.internal
VIEW_THEME=default
// Read in code:
$env = $this->c->config->get('env'); // "prod"
$host = $this->c->config->get('db.host'); // dot-path
$ttl = $this->c->config->get('cache.ttl', 600); // with default
// Or read .env directly (before container is built):
\BC\Core\Env::get('STRIPE_SECRET', '');
Composer / packages
Composer is supported but not required. The framework autoloader auto-includes vendor/autoload.php if present, so any PSR-4 package is callable the moment composer require finishes.
# Install all declared dependencies (after cloning the repo):
composer install
# Add a new package:
composer require stripe/stripe-php
composer require paypal/paypal-server-sdk
composer require braintree/braintree_php
composer require guzzlehttp/guzzle
# Remove a package:
composer remove stripe/stripe-php
# Regenerate the autoload classmap (also done automatically by install/update):
composer dump-autoload --optimize
Use the package immediately - no extra wiring:
// app/Controllers/Checkout.php
namespace App\Controllers;
use BC\Core\Mvc\Controller;
final class Checkout extends Controller {
public function index(): string {
// Stripe (after `composer require stripe/stripe-php`):
$stripe = new \Stripe\StripeClient(
\BC\Core\Env::get('STRIPE_SECRET')
);
$session = $stripe->checkout->sessions->create([…]);
// Braintree (after `composer require braintree/braintree_php`):
$gateway = new \Braintree\Gateway([
'environment' => \BC\Core\Env::get('BRAINTREE_ENV', 'sandbox'),
'merchantId' => \BC\Core\Env::get('BRAINTREE_MERCHANT_ID'),
'publicKey' => \BC\Core\Env::get('BRAINTREE_PUBLIC_KEY'),
'privateKey' => \BC\Core\Env::get('BRAINTREE_PRIVATE_KEY'),
]);
return $this->view->render('index', ['session' => $session]);
}
}
How autoload resolves a class:
BC\Core\…→ handled by framework autoloader (core/Autoload.php) - instant.App\…→ framework autoloader - instant.Demo\…→ framework autoloader - instant.- Everything else → falls through to Composer's autoloader (
vendor/autoload.php) - handles allvendor/*packages.
Tip: in production, run composer install --no-dev --optimize-autoloader --classmap-authoritative for the fastest possible autoload (no filesystem lookups; one in-memory class map).
Already-listed suggestions in composer.json - view with composer suggests:
ext-redis phpredis for cache.driver=redis
ext-apcu APCu for cache.driver=apcu
ext-gd GD for image-resize feature
stripe/stripe-php Stripe payments
paypal/rest-api-sdk-php PayPal REST SDK (classic)
paypal/paypal-server-sdk PayPal Server SDK (newer)
braintree/braintree_php Braintree payments
phpmailer/phpmailer Alternative SMTP transport
guzzlehttp/guzzle Alternative HTTP client
monolog/monolog Drop-in for the built-in Logger
nesbot/carbon Rich date/time helpers
Middleware
Configured pipeline runs in order before the Dispatcher. Each entry must implement MiddlewareInterface.
// config/config.php
'middleware' => [
\BC\Core\Http\Middleware\MaintenanceMode::class, // 503 when flag set
\BC\Core\Http\Middleware\RateLimit::class, // throttle per IP/user
\BC\Core\Http\Middleware\VerifyCsrf::class, // token check for non-GET
],
// Write your own:
namespace App\Http\Middleware;
final class LogRequest implements \BC\Core\Http\MiddlewareInterface {
public function __construct(private \BC\Core\Container $c) {}
public function handle(\BC\Core\Http\Request $r, callable $next): string {
$this->c->logger->info("$r->method $r->path from $r->ip");
return $next($r); // pass downstream
}
}
Short-circuit by returning a string instead of calling $next() - useful for early redirects, blocked IPs, or maintenance windows.
Rate limiter
Built-in RateLimit middleware throttles mutating requests (POST / PUT / PATCH / DELETE) based on rate_limit.* config. GET / HEAD / OPTIONS are exempt by default - a browser refresh, a prefetcher, or a social-card scrape must never trip a security throttle. Requests from security.safe_ips (loopback + operator IPs) are exempt unconditionally. For per-action limits (e.g. login attempts), use the service directly:
$rl = $this->c->rateLimiter();
if (!$rl->attempt('login:' . $this->request->ip, max: 5, windowSeconds: 60)) {
return $this->fail('Too many attempts. Try again in a minute.', 429);
}
// proceed with login…
$rl->reset('login:' . $this->request->ip); // after successful login
Backed by the configured cache driver. Redis gives atomic counters across multiple web nodes. Set rate_limit.include_safe = true to count GETs too (e.g. scrape protection on a public API).
Pagination
$page = $this->c->db()->paginate(
"SELECT * FROM posts WHERE published_at <= NOW() ORDER BY id DESC",
params: [],
perPage: 20,
currentPage: (int) ($this->url->get['p'] ?? 1),
);
return $this->view->render('index', [
'rows' => $page->rows,
'total' => $page->total,
'lastPage' => $page->lastPage,
'paginator' => $page, // pass into the view
]);
// In the view:
<?php foreach ($paginator->rows as $row): ?>
<li><?= e($row->title) ?></li>
<?php endforeach; ?>
<?= $paginator->html('p') ?> // "‹ Prev 1 2 [3] 4 5 Next ›"
Hostile ?page=99999999 would otherwise force a huge OFFSET scan, so paginate() clamps perPage and currentPage against db.paginate_max_per_page (default 1000) and db.paginate_max_page (default 100 000).
Block caching
Cache an expensive chunk of a view. Hits return in <0.1 ms; misses run the producer + store the result. The profiler shows block.<name>.hit / .miss with ?_speed=1.
// Functional form:
<?= block('home.features.v1', 600, function () use ($features) {
ob_start();
foreach ($features as $f) echo '<li>' . e($f[0]) . '</li>';
return ob_get_clean();
}) ?>
// Imperative form - start/end pair:
<?php if (!cache_start('home.features.v1', 600)): ?>
<section class="features">
<!-- expensive content built here -->
</section>
<?php cache_end(); endif; ?>
Suffix your key (.v1, .v2) - bumping the suffix invalidates without flushing the whole cache.
HTTP client
Outbound HTTP without Composer. Fluent, retries on 5xx with exponential backoff.
$http = $this->c->http();
// Simple GET:
$resp = $http->get('https://api.example.com/things')->send();
if ($resp->ok()) {
$data = $resp->json();
}
// POST JSON with retry:
$resp = $http->post('https://api.example.com/orders')
->withHeader('Authorization', 'Bearer ' . $token)
->withTimeout(8)
->withRetries(3)
->withJson(['amount' => 4200])
->send();
// One-shot JSON helper:
$data = $http->json('GET', 'https://api.example.com/health');
For richer features (HTTP/2 multiplex, signed URLs, middleware stack), composer require guzzlehttp/guzzle.
File uploads & Image processing
Safe by default: MIME-sniffed, extension allowlist, randomised filename, stored outside web-executable scope.
// In a controller - assume your form has <input name="avatar" type="file">
$upload = new \BC\Core\Files\Upload(
$this->request->files['avatar'] ?? [],
allow: ['png', 'jpg', 'jpeg', 'webp'],
maxBytes: 4 * 1024 * 1024,
);
if (!$upload->valid()) {
return $this->fail($upload->error(), 422);
}
$savedPath = $upload->store(); // /…/storage/uploads/<random>.jpg
// Resize / re-encode via GD:
\BC\Core\Files\Image::open($savedPath)
->resize(800, 800)
->saveAs($savedPath . '.thumb.webp', quality: 82);
The uploads folder ships with an .htaccess denying .php/.phtml/.cgi execution - a renamed PHP file inside storage/uploads/ is served as text, not executed.
Mailer
Transports: log (default - writes to logs/mail.log), sendmail (PHP mail()), smtp (raw socket with STARTTLS + AUTH).
// .env
MAIL_TRANSPORT=smtp
SMTP_HOST=smtp.mailgun.org
SMTP_PORT=587
SMTP_USER=postmaster@yourdomain
SMTP_PASS=secret
SMTP_ENCRYPTION=tls
MAIL_FROM_ADDRESS=hello@yourdomain.com
MAIL_FROM_NAME=Your Site
// In a controller:
$message = (new \BC\Core\Mail\Message())
->to('user@example.com')
->subject('Welcome to ' . $this->c->config->get('site.name'))
->html($this->view->render('emails/welcome', ['name' => 'Alice']))
->text('Hi Alice - welcome!');
$ok = $this->c->mailer()->send($message);
Render emails from views - same template engine, same cascade. Or composer require phpmailer/phpmailer for DKIM-signing, embedded images, etc.
Queue
Drivers: sync (runs inline; default in dev), database (jobs table), redis (lists). Workers via bin/bc queue:work.
// Define a job - app/Jobs/SendWelcomeEmail.php
namespace App\Jobs;
final class SendWelcomeEmail extends \BC\Core\Queue\Job {
public function __construct(public int $userId) {}
public function handle(): void {
$user = (new \App\Models\User())->find($this->userId);
// …send the email…
}
public function maxTries(): int { return 3; }
public function retryAfter(): int { return 60; }
}
// Dispatch from anywhere:
$this->c->queue()->push(new \App\Jobs\SendWelcomeEmail($newUserId));
// Run a worker:
// bin/bc queue:work # default queue, 1s sleep
// bin/bc queue:work emails 2 # 'emails' queue, 2s sleep between polls
DB driver needs the jobs table - see core/Queue/DatabaseQueue.php docblock for SQL. Redis driver requires cache.driver = redis.
Scheduler / Cron
Declare tasks in app/schedule.php. Run bin/bc schedule:run every minute from a SINGLE crontab line - the scheduler decides which tasks are due.
// app/schedule.php
return function (\BC\Core\Schedule\Scheduler $s): void {
$s->call(static fn() => \App\Jobs\CleanOrphans::run())
->hourly()
->name('clean.orphans')
->withoutOverlapping();
$s->call([\App\Reports\Digest::class, 'send'])
->dailyAt('06:00');
$s->call(static fn() => \BC\Core\Container::instance()->cache()->clear())
->cron('*/15 * * * *'); // raw 5-field cron expression
};
# /etc/crontab - ONE line for the whole framework:
* * * * * cd /var/www/yourapp && bin/bc schedule:run >/dev/null 2>&1
Fluent shortcuts: everyMinute, everyFiveMinutes, hourly, daily, dailyAt('HH:MM'), weekly, monthly, or cron('5-field expr').
i18n
File-based translations under app/lang/<locale>/<file>.php. Each file returns a flat array. Look up with t('file.key').
// app/lang/en/common.php
return [
'welcome' => 'Welcome',
'hello' => 'Hello, :name',
'sign_in' => 'Sign in',
];
// Anywhere - template, controller, mailer:
echo t('common.welcome'); // "Welcome"
echo t('common.hello', ['name' => 'Alice']); // "Hello, Alice"
// Switch locale per request:
$this->c->translator()->setLocale('es');
Assets / Tailwind
Pluggable compiler. Default: Tailwind via the standalone CLI (no Node required).
# One-off, downloads the platform binary (~30 MB, MIT, by Tailwind Labs):
bin/bc assets:install tailwind
# One-shot build:
bin/bc assets:build
# Watch mode for dev:
bin/bc assets:build --watch
# In your layout:
<link rel="stylesheet" href="<?= asset('app.css') ?>">
The asset() helper reads public/build/manifest.json and returns a content-hashed URL like /public/build/a1b2c3-app.css. When no build exists yet, it falls back to /public/css/site.css.
Source lives at resources/css/app.css:
@import "tailwindcss";
/* your customisations… */
Swap compilers via ASSETS_COMPILER=tailwind|none in .env. Implementing your own: class XCompiler implements \BC\Core\Assets\CompilerInterface { … } and register in CompilerManager::make().
SEO & page metadata
Every Controller gets a $this->seo bundle pre-populated from seo.* config. The default layout renders the full meta block - viewport, description, robots, canonical, Open Graph, Twitter Card, JSON-LD, theme-color - via core/Views/partials/seo.php. Override per page:
public function view(): string {
$post = $this->c->db()->row(
"SELECT * FROM posts WHERE slug = :s LIMIT 1",
['s' => $this->sanitize->wrap($this->url->param['slug'] ?? '')->slug]
);
if ($post === null) return $this->view->render('system/fourzerofour');
$this->seo
->setTitle($post->title)
->setDescription($post->summary)
->setCanonical($this->c->config->get('site.url') . '/blog/' . $post->slug)
->setImage('https://example.com/og/' . $post->slug . '.jpg', $post->title)
->setType('article')
->addJsonLd([
'@context' => 'https://schema.org',
'@type' => 'Article',
'headline' => $post->title,
'datePublished' => $post->published_at,
'author' => ['@type' => 'Person', 'name' => $post->author],
])
->addMeta('article:section', $post->category, isProperty: true)
->addLink('alternate', '/feed.xml', ['type' => 'application/atom+xml']);
return $this->view->render('view', ['post' => $post]);
}
Defaults like seo.site_name, seo.title_template, seo.default_image, seo.twitter_site, seo.theme_color live in config/config.php. The bundle de-duplicates: if your $title already contains the site name it's emitted verbatim instead of being concatenated again.
Language & locale
URL-prefix routing + Accept-Language negotiation, baked into the URL parser:
// config/config.php
'seo' => [
'available_languages' => ['en', 'en-gb', 'fr', 'de-at'], // BCP-47, lowercase
'language' => 'en', // fallback when nothing else matches
'locale' => 'en_GB', // fallback OG locale
],
// Behaviour:
GET /en-gb/about → routes to About::index(), lang="en-gb", og:locale="en_GB"
GET /fr/blog → routes to Blog::index(), lang="fr", og:locale="fr"
GET /about → URL prefix absent →
picks best match from Accept-Language header,
falls back to seo.language otherwise
GET /xx/something → "xx" not in available_languages →
falls through as a normal /xx/something path
The resolved language drives three things at once: <html lang>, Content-Language HTTP header (also on JSON API responses), and og:locale. Inside a controller: $this->url->locale (e.g. "en-gb" or null); $this->seo->language (always set).
Accept-Language parsing is q-weighted with base-language fallback - a client requesting fr-CA;q=0.9, en;q=0.8 against an ['fr', 'en'] offer list matches fr. Leave available_languages empty to disable URL/negotiation entirely.
Compression
The Compress middleware runs last in the pipeline and gzips the response when:
1. Client sent Accept-Encoding: gzip
2. Body size ≥ http.compress.min_bytes (default 1024)
3. Content-Type isn't already-compressed (image/*, video/*, audio/*,
application/pdf, application/zip, …)
4. No upstream zlib output handler is active, no manual Content-Encoding set
Always sets:
Vary: Accept-Encoding ← so caches don't serve a gzipped body
to a non-gzip client
Content-Length: <encoded size>
# Config knobs (config.php)
'http' => [
'compress' => [
'enabled' => true,
'min_bytes' => 1024,
'level' => 5, // 1 (fast) … 9 (small)
],
],
A ~4.5 KB HTML home page compresses to ~1.7 KB on the wire. Lighthouse / PageSpeed lift the perf score immediately. The Brotli path falls back to gzip - if you front the app with nginx/Caddy, let the edge do Brotli and leave gzip here as a defence-in-depth.
Request body & JSON limits
Set during Request::capture(), before any controller runs - protects the worker process from memory exhaustion via giant uploads or deeply nested JSON.
// config.php
'http' => [
'max_body_bytes' => 1_048_576, // 1 MB raw body ceiling → 413 if exceeded
'json_max_depth' => 64, // json_decode depth → silently drops $json
],
Over-cap bodies return 413 Payload Too Large immediately - never reach the controller. Too-deep JSON resolves to $request->json === null, so the controller decides whether absence is a 400.
Image proxy & lazy loading
Every <img> can be routed through /img/{spec}/{source} - the framework resizes, re-encodes (WebP when the browser asks), and disk-caches the variant. Source images live in public/uploads/, public/images/, or storage/ (any path in img.source_roots).
// In a template - lazy-loading, layout-stable, cache-friendly:
<?= img('/uploads/hero.jpg', width: 1200, height: 600, alt: 'Hero') ?>
<?= img('/images/logo.svg', width: 240, alt: 'Logo') ?> // SVG: pass-through
<?= img('/uploads/avatar.jpg', width: 96, alt: '',
attrs: ['class' => 'avatar', 'sizes' => '96px']) ?>
// Picture element with explicit WebP source (cleanest CDN behaviour):
<?= picture('/uploads/hero.jpg', width: 1200, height: 600, alt: 'Hero') ?>
// URL shape: /img/{w800-h600-q70-fwebp}/{source}
// w800 → target width
// h600 → target height (omit to preserve aspect)
// q70 → JPEG/WebP quality 1-100 (default 82)
// fwebp → force WebP (or fjpg, fpng) - overrides Accept negotiation
//
// Examples:
// /img/w800/uploads/photo.jpg
// /img/w400-h400-q70/avatars/me.jpg
// /img/w1200-fwebp/posts/cover.png
// Every <img> emitted by img() always includes:
// loading="lazy" decoding="async" width="…" height="…"
// → off-screen images deferred; the layout reserves the box (Lighthouse CLS).
// config.php
'img' => [
'source_roots' => [BC_ROOT . '/public', BC_ROOT . '/storage'],
'max_width' => 2400, // any larger request is clamped
'max_height' => 2400,
'default_width' => 1600, // when img() is called with no width
'quality' => 82,
],
Source 24 KB JPEG → WebP 1 KB on /img/w800 in our test. Path-jail blocks .. / null-byte traversal. ETag + If-None-Match yields 304 on second hit. The proxy never recompresses already-compressed bytes via the gzip middleware (it strips Content-Encoding). Animated GIF / animated WebP lose animation - GD is single-frame; serve those as direct static files instead.
CDN - images + static
Drop one URL in .env and every img(), picture(), and asset() URL is rewritten to go through the CDN. Long-cache headers (max-age=1y, immutable) are already on the responses, so the CDN caches aggressively without extra config.
# .env
CDN_URL=https://cdn.example.com # both static + images
# or split per kind:
CDN_ASSETS_URL=https://static.cdn.example.com # CSS/JS/fonts via asset()
CDN_IMAGES_URL=https://img.cdn.example.com # images via img()/picture()
// At the CDN (Bunny / Fastly / KeyCDN / Cloudflare …):
// Origin: https://yoursite.com
// Cache rule: respect origin Cache-Control (do not override)
// Cover paths: /img/*, /css/*, /js/*, /build/*, /uploads/*
// Honour Vary: Accept (recommended - separate WebP / non-WebP entries)
// In templates - nothing changes:
<?= img('/uploads/hero.jpg', width: 1200, alt: 'Hero') ?>
// emits: <img src="https://cdn.example.com/img/w1200/uploads/hero.jpg" …>
// Build absolute CDN URLs manually when you need them in JSON / emails:
cdn_url('/css/site.css') // → https://cdn…/css/site.css
cdn_url('/img/w800/foo.jpg', kind: 'images') // → https://img.cdn…/img/w800/foo.jpg
cdn_url('https://other.host/foo.jpg') // → unchanged (absolute URLs pass through)
Vary: Accept gotcha. Cloudflare's free tier ignores Vary: Accept; the first cold-edge request "trains" the cache for everyone. Workarounds: use a CDN that respects Vary (Bunny, Fastly), or use picture() which forces a separate URL per format (w800-fwebp vs w800-fjpg) so each variant gets its own cache entry. Cloudflare's Polish / Image Resizing does its own conversion at the edge.
Cache busting. Dimension changes get a new URL automatically (w800 ≠ w1200). To replace a source image without touching the URL, either purge the CDN path or rename the source (hero-v2.jpg). Leaving CDN_URL blank in dev means bare paths - no CDN, no rewrites - same template code, no env-specific branches.
Deployment & public/ as docroot
The safe production layout points the webserver document root at public/ - every sensitive directory (.env, vendor/, core/, config/, logs/, storage/) is physically outside the webroot and unreachable, regardless of .htaccess health.
your-site/ ← project root (NOT webroot)
├── .env ← secrets, unreachable
├── .git/ ← unreachable
├── composer.json/.lock ← unreachable
├── app/ bin/ cache/ ← unreachable
├── config/ core/ demo/ ← unreachable
├── logs/ resources/ ← unreachable
├── storage/ vendor/ ← unreachable
├── index.php ← legacy entrypoint (still works)
├── .htaccess ← hardened fallback for legacy layout
└── public/ ← ← ← DOCUMENT ROOT POINTS HERE
├── .htaccess ← short, just rewrites + headers
├── index.php ← canonical front controller (BC_ROOT = ..)
├── css/ js/ build/ ← static assets
└── uploads/ images/ ← image-proxy source roots
# Local dev - bin/bc serve auto-detects public/:
bin/bc serve # → docroot: public/
bin/bc serve 127.0.0.1:8080
# Equivalent raw command:
php -d auto_prepend_file= -S 127.0.0.1:8080 -t public public/index.php
# Verify hardening on the live site (every line must return 403 or 404):
curl -I https://yoursite.com/.env
curl -I https://yoursite.com/.git/config
curl -I https://yoursite.com/composer.lock
curl -I https://yoursite.com/vendor/autoload.php
curl -I https://yoursite.com/core/Boot.php
curl -I https://yoursite.com/config/config.php
curl -I https://yoursite.com/logs/app.log
Unsafe-docroot detector. If a request reaches the legacy root index.php (i.e. the docroot is misconfigured), the framework injects a fixed bottom-right notification banner into the rendered HTML: "Insecure deployment - point the docroot at …/public". The banner is dismissible for 24 hours. CSS + JS live in public/css/bc-banner.css and public/js/bc-banner.js so a strict CSP (style-src 'self'; script-src 'self') lets them load without 'unsafe-inline'. Once the docroot is flipped to public/ the banner stops appearing because the legacy entrypoint never runs.
nginx. Apache's .htaccess rules are ignored entirely by nginx - so on nginx, public-as-docroot is the only safe layout. The server block needs root /path/to/your-site/public; try_files $uri /index.php?$query_string; and a location ~ \.php$ block that proxies to PHP-FPM.
CLI - bin/bc
bin/bc # list commands
bin/bc serve [host:port] # PHP built-in dev server (default 127.0.0.1:8080)
bin/bc queue:work [queue] [sleep] # process jobs (long-running)
bin/bc schedule:run # fire due scheduled tasks
bin/bc cache:clear # wipe the configured cache backend
bin/bc make:controller Product # scaffold app/Controllers/Product.php
bin/bc assets:install tailwind # download the Tailwind binary
bin/bc assets:build [--watch] # compile CSS
Add your own command at app/Commands/<Name>Command.php extending \BC\Core\Cli\Command - register in Cli\Kernel::$commands.
Log viewer (key-gated)
Web UI for tailing log files. Disabled by default. When enabled, requires a passphrase - wrong/missing key returns 404, identical to a missing route, so nothing leaks.
# .env
LOG_VIEWER_ENABLED=true
LOG_VIEWER_PATH=/_logs # change for obscurity
LOG_VIEWER_KEY=long-random-passphrase
# Then open:
http://yoursite/_logs?key=long-random-passphrase
# Or via header:
curl -H "X-Log-Viewer-Key: long-random-passphrase" http://yoursite/_logs
Renders its own self-contained HTML - works even when the rest of the app is broken (which is when you need it). Auto-discovers every .log in logs/; backward-tails by chunk reads (handles 100 MB+ logs efficiently); colourises ERROR/FATAL/WARNING lines; filter by substring.
Cheat sheet - fastest path to each thing
| I want to… | Call this |
|---|---|
| Get a clean email from POST | $this->sanitize->wrap($_POST['email']??'')->email |
| Fetch a row by id | $db->row("SELECT * FROM t WHERE id=:i",['i'=>$id]) |
| Insert and get the new id | $db->execute("INSERT INTO t …", $params) |
| Cache a value for 10 min | $this->c->cache()->set('k', $v, 600) |
| Render a page | return $this->view->render('index', $data) |
| Redirect | $this->redirect('/path') |
| JSON response | return $this->ok($data) |
| CSRF in a form | <?= csrf_field() ?> |
| Escape in a view | <?= e($value) ?> |
| Profile a block | $p->mark('x'); …; $p->end('x'); |
| Log an event | $this->c->logger->info('msg') |
| Read config | $this->c->config->get('db.host') |
| Send an email | $this->c->mailer()->send($message) |
| Queue a job | $this->c->queue()->push(new SendWelcome($id)) |
| Paginate a query | $db->paginate($sql, $p, 20, $page) |
| Block-cache a fragment | block('home.v1', 600, fn() => ...) |
| HTTP GET | $http->get($url)->send()->json() |
| Translate a string | t('common.welcome') |
| Hashed asset URL | <?= asset('app.css') ?> |
| Resize an image | Image::open($p)->resize(800,800)->saveAs($out) |
| Validate an upload | new Upload($_FILES['avatar'], allow: ['png','jpg']) |
| Rate-limit an action | $rl->attempt('login:'.$ip, 5, 60) |
| Set the page title | $this->seo->setTitle('Pricing') |
| Set OG / Twitter image | $this->seo->setImage('https://…/og.jpg', 'Alt') |
| Set canonical URL | $this->seo->setCanonical('https://…/path') |
| Add JSON-LD schema | $this->seo->addJsonLd(['@context'=>…]) |
| Mark page as noindex | $this->seo->setRobots('noindex, nofollow') |
| Enable Google Analytics | GA_ID=G-XXXXXXXXXX in .env |
| Enable Google Tag Manager | GTM_ID=GTM-XXXXXXX in .env |
| Read URL language | $this->url->locale (or null) |
| Force a language per page | $this->seo->setLanguage('fr') |
| Read raw JSON body | $body = $this->request->json ?? [] |
| Render an image (lazy + WebP) | img('/uploads/x.jpg', width: 800, alt: '…') |
| Picture w/ explicit WebP source | picture('/uploads/x.jpg', width: 800, alt: '…') |
| Enable CDN | CDN_URL=https://cdn… in .env |
| Absolute CDN URL by hand | cdn_url('/css/site.css') |
| Where source images live | public/uploads/ (or anywhere in img.source_roots) |
| Safe deploy layout | point web docroot at …/public |