blackCurrant PHP · v1.0 · PHP 8.1+

Ship a fast, secure, SEO-perfect site
in PHP - without the framework tax.

Convention-based routing, baked-in Lighthouse hygiene, image proxy with WebP & lazy loading, CDN-ready URLs, operator-gated debug tools, and a core that adds well under a millisecond of overhead per warm request.

View all features → See it styled

  • ~0.5 mscore overhead, warm request
  • 0required dependencies
  • 10CSS variables, theme-editable
  • WebPauto, browser-negotiated

Measured boot + dispatch on a warm request. The first request after a restart compiles the code (a few ms, once); production runs OPcache so bytecode stays warm across every request.

Agent-ready: your app speaks MCP

AI agents are becoming customers. Define a capability once and the framework exposes it over MCP, REST and the CLI at the same time - so ChatGPT, Claude, Perplexity and your own bots can use your app. Built in, not bolted on.

Define a capability once

One small class - a name, a JSON-schema for its args, and run(). The framework owns transport, auth, validation and audit.

final class CatalogSearch extends AbstractCapability {
    public function name(): string { return 'catalog.search'; }
    public function run($args, $ctx): mixed {
        return $this->c->db()->all("SELECT ...");
    }
}

MCP + REST + CLI, free

The same capability is reachable by every agent surface - no extra wiring.

# MCP   POST /mcp                    (tools/call)
# REST  POST /agent/v1/catalog.search
# CLI   bin/bc ai:call catalog.search

Discoverable + audited

Agents find you via /llms.txt and /.well-known/agent.json; bearer-token auth gates writes; every call lands in logs/agent.log.

GET /llms.txt
GET /.well-known/agent.json
Authorization: Bearer <token>

Agentic commerce on top

Core ships the rails, not a store. Add cart.add / checkout.complete in app/ - aligned with the ACP / UCP protocols and AP2 payment mandates.

// app/Ai/CheckoutComplete.php
public function readOnly(): bool { return false; } // authed

Built for speed

Cold path measured in fractions of a millisecond. Images, assets, and responses optimised before they leave the server.

Image proxy with WebP + lazy loading

Path-based URLs, browser-aware format negotiation, on-disk cache, ETag + 304s. 24 KB → 1 KB on a typical hero shot.

<?= img('/uploads/hero.jpg',
        width: 1200, height: 600,
        alt: 'Hero shot') ?>
<!-- emits:
  <img src="/img/w1200-h600/uploads/hero.jpg"
       width="1200" height="600"
       loading="lazy" decoding="async"
       alt="Hero shot"> -->

Gzip middleware

Compresses every text response, sets Vary: Accept-Encoding, skips already-compressed MIME types. ~70% smaller HTML on the wire.

// config.php
'http' => ['compress' => [
    'enabled'   => true,
    'min_bytes' => 1024,
    'level'     => 5,
]]

CDN - one env var

Every img(), picture(), asset() URL gets rewritten to the CDN host. Long-cache headers are already set.

# .env
CDN_URL=https://cdn.example.com
# split per kind, optional:
CDN_IMAGES_URL=https://img.cdn.example.com
CDN_ASSETS_URL=https://static.cdn.example.com

Block cache, page cache, profiler

Wrap any expensive fragment in a TTL'd block. Hit ?_speed=1 from a safe IP for a per-stage timing strip.

<?= block('home.featured.v1', 600,
    fn() => $db->all("SELECT ...")
) ?>

Google ranks it. Sharing tools render it.

Every meta tag a search engine, social card, or LLM crawler looks for is emitted by default. Override one line per page.

Full meta bundle

Title, description, canonical, robots, OG, Twitter Card, JSON-LD, theme-color - all rendered by core/Views/partials/seo.php.

$this->seo
    ->setTitle($post->title)
    ->setDescription($post->summary)
    ->setCanonical($post->url)
    ->setImage($post->og_image, $post->title)
    ->setType('article')
    ->addJsonLd([
        '@context' => 'https://schema.org',
        '@type'    => 'Article',
        'headline' => $post->title,
    ]);

Google Analytics & Tag Manager

Drop a measurement ID in .env - gtag.js + GTM head + GTM <noscript> body are injected. Format-validated to prevent JS smuggling.

# .env
GA_ID=G-XXXXXXXXXX        # GA4 Measurement ID
GTM_ID=GTM-XXXXXXX        # GTM container ID
# That's it - no template edits.

Language URL routing

/en-gb/about resolves to About::index() with $url->locale = 'en-gb'. Content-Language header + <html lang> + og:locale all match.

// config.php
'seo' => [
    'available_languages' => ['en', 'en-gb', 'fr'],
],
// Auto-negotiates Accept-Language too,
// with q-weighted base-language fallback.

Mobile-first by default

Viewport meta, theme-color, decoding="async" + explicit width/height on images (no CLS), color-scheme set. Lighthouse out of the box.

<meta name="viewport"
    content="width=device-width,
             initial-scale=1,
             viewport-fit=cover">

Hardened on day one

Defence-in-depth from the docroot up. Sensible defaults that ship safe instead of "secure once you wire it up".

public/ as docroot

.env, vendor/, core/, config/, storage/ live outside the webroot. A misconfigured .htaccess rule can't leak them. Visible banner if your docroot is misconfigured.

your-site/
├── .env          ← unreachable
├── vendor/       ← unreachable
├── core/         ← unreachable
└── public/       ← ← DOCROOT HERE
    └── index.php

Operator unlock - no IP allowlist required

Set a passphrase in .env. Visit ?_op=<key> once - cookie sticks for a year. /debug, /theme/save, the framework nav are gated through it.

# .env
OPERATOR_KEY=long-random-passphrase

# Then once:
https://yoursite/?_op=long-random-passphrase
# → bc_op_mode cookie set (Secure, HttpOnly,
#   SameSite=Lax, year expiry)

CSRF, body limits, scheme allowlist

Timing-safe CSRF on every mutating request. JSON body cap + depth limit. CleanInput::url() rejects javascript: / data: / file: schemes.

// config.php
'http' => [
    'max_body_bytes' => 1_048_576,
    'json_max_depth' => 64,
],
'security' => [
    'csrf'         => true,
    'csp'          => "default-src 'self'; ...",
    'safe_ips'     => ['127.0.0.1', '::1'],
    'operator_key' => Env::get('OPERATOR_KEY', ''),
],

Rate-limit that doesn't trip browsers

Mutating methods only (POST / PUT / PATCH / DELETE). Safe IPs exempt. A frantic browser refresh never returns 429.

// Per-action limit for login etc.
$rl = $this->c->rateLimiter();
if (!$rl->attempt('login:'.$ip, 5, 60)) {
    return $this->fail('Too many', 429);
}

Take it for a spin

Every framework subsystem has a live page you can poke. Hit any of these - they all read the same theme variables.

Add a page in 60 seconds

1. Create the controller.

// app/Controllers/Blog.php
namespace App\Controllers;

final class Blog extends \BC\Core\Mvc\Controller
{
    public function index(): string {
        $posts = $this->c->db()->all(
          "SELECT * FROM posts ORDER BY id DESC LIMIT 20"
        );
        return $this->view->render('index', ['posts' => $posts]);
    }
}

2. Drop a view alongside it.

<?php /* app/Views/default/blog/index.php */ ?>
<section>
  <h1>Latest posts</h1>
  <ul>
  <?php foreach ($posts as $p): ?>
    <li>
      <a href="/blog/view/slug/<?= eUrl($p->slug) ?>">
        <?= e($p->title) ?>
      </a>
    </li>
  <?php endforeach; ?>
  </ul>
</section>

3. Done - /blog resolves to Blog::index(). No route table, no migration, no scaffolding command.

Anatomy of this page

HEADER app/Views/partials/header.php
BODY (you are here) app/Views/home/index.php

All three blocks are composed by the app layout (app/Views/layouts/app.php). Any of them can be overridden per-controller or replaced entirely - drop a file at app/Views/<classFolder>/<name>.php and the View engine picks it up automatically.