Djebel vs WordPress: Two Different Assumptions
WordPress runs a large share of the web, and it earned that. It solved a real problem — give people a way to publish without touching a server — and it solved it for an era when "a website" meant "a database-backed CMS with an admin panel."
Djebel starts somewhere else. Not because WordPress got it wrong, but because the assumption underneath it is worth questioning now:
WordPress assumes a database, an admin UI and a plugin marketplace. Djebel assumes files and a request.
Almost everything below follows from that one sentence. If you take nothing else from this post, take that — the table is mostly a list of consequences.
The Table
| Topic | Djebel | WordPress |
|---|---|---|
| Database | Not required | Required |
| Content model | None in core — a plugin supplies one | Posts, pages and taxonomies, in core |
| Themes | Optional | Required — an invalid one is switched out for the default |
| Plugin tiers | System, shared, non-public, regular | Must-use, network-activated (multisite), active |
| Which plugins are active | The directories that exist | The active_plugins option, in the database |
| Conditional loading | load_if_url — a config line per plugin |
Filter option_active_plugins yourself |
| Private plugins | djebel-site-<name> — the name is the declaration |
No core convention; mu-plugins by habit |
| Plugins off the web | A plugins dir outside the document root | wp-content is under the document root |
| Headless | app.core.headless toggle |
WP_USE_THEMES, or bypass index.php |
| Config sources | Two tiers kept apart: env/constants vs app.ini |
wp-config.php constants + the options table |
| The framework itself | A standalone directory or PHAR outside the site, upgraded on its own | Core lives in the document root and can update itself |
| Install | Copy files, point a loader at the framework | Run the installer, which creates the tables |
| Updates | Move a pinned commit, per site | Admin, WP-CLI, or auto-updates |
| Hooks | Actions and filters | Actions and filters |
| Whole page output | app.page.full_content filters the entire buffer |
No core hook — ob_start() yourself |
| Assets | Dj_App_Assets::register(), any time before the page is assembled |
wp_enqueue_script() / wp_enqueue_style(), on or after init |
| Asset URLs | Plugin/theme context resolves it; inlined when the directory is not web-reachable | plugins_url() / get_template_directory_uri(), assembled by you |
| Shared libraries | Declared once in the site's app.ini, registered before any plugin runs |
Core bundles a set by handle; anything else, some plugin has to register it |
| Cache busting | ?v=filemtime by default, or a version you pass |
The $ver argument; the default is the WordPress version |
| Minified builds | Automatic when live — uses .min beside the source, falls back if absent, per-asset opt-out |
SCRIPT_DEBUG + wp_scripts_get_suffix(); core's own assets, and you build the filename |
| Libraries | Dj_App_Lib::loadLib(), lazy or eager |
Bundled per plugin |
Some of those rows are fair as one-liners. Some are not, and those get a section.
Databases and Content
This is the load-bearing difference.
WordPress needs a database to boot. require_wp_db() runs during startup, and a failed connection ends in dead_db() — the "Error establishing a database connection" page everyone has seen. Posts, pages, options, and most plugin settings are rows.
Djebel does not require one. Config is files, and a site can serve a full page having opened nothing but the filesystem.
But the sharper difference is that Djebel core has no content model at all. WordPress ships one: posts, pages, revisions, taxonomies, and the schema behind them. Djebel core ships a page object with content you set — where that content comes from is a plugin's decision. This very blog is a plugin reading Markdown files off disk; another site could just as well pull from an API, or from a database, because core has no opinion.
That cuts both ways, and it is worth being blunt about which way. WordPress hands you a content model on day one and you build on it. Djebel hands you nothing and you pick — which is freedom if you have a specific shape in mind, and homework if you just wanted to publish an article.
There is a database layer in core (Dj_App_Db, built on Doctrine DBAL) for the cases that want one, but nothing loads it at bootstrap. You reach for it or you never pay for it.
Neither model is automatically better. A database gives you querying, concurrent editing, and an admin UI that can write content without shell access — real things that real teams need. Files give you version control that actually works, deploys that are a copy, and no migration step. If your content changes hourly and is edited by ten people, the database is doing something for you. If it changes weekly and lives in git anyway, you are paying for a dependency you never query.
Themes
WordPress requires a theme. validate_current_theme() checks the active theme for index.php (or templates/index.html) plus style.css, and if the theme does not qualify, WordPress switches to the default theme rather than rendering without one.
In Djebel a theme is optional, and skipping it is a supported configuration rather than a broken state — useful for APIs, headless setups, and sites where plugins render everything. There is a whole post on it: Themes Are Optional in Djebel.
Configuration: Two Tiers That Do Not Talk To Each Other
WordPress puts configuration in two places and lets them blur: constants in wp-config.php, settings in the options table, and plenty of things readable from either.
Djebel keeps them deliberately separate, and the framework's own config reference calls mixing them up the most common mistake people make:
Dj_App_Config::cfg($key, $default) |
Dj_App_Options::getInstance()->get($key, $default) |
|
|---|---|---|
| Reads | Environment variables + PHP constants | app.ini |
| Does not read | app.ini |
Env vars or constants |
| Set by | The hosting environment, .env, define() |
The site owner, in a text file |
| For | Infrastructure — where things live, kill switches | Site behaviour — title, theme, plugin settings |
cfg() never opens app.ini. A key read only through cfg() cannot be set from app.ini no matter how you spell it there — and that is not an oversight. Infrastructure belongs to whoever runs the server; site behaviour belongs to whoever owns the site. The two are different people often enough that the framework refuses to pretend otherwise.
When a key genuinely should be settable from both, someone bridges it explicitly — options first, cfg() as the fallback:
$load_libs = $options_obj->get('app.load_libs', Dj_App_Config::cfg('app.core.load_libs'));
The cost is real: you have to know which tier a key lives in, and "I set it and nothing happened" is the failure mode when you guess wrong. The benefit is that a deploy cannot be broken by an editable text file, and a site owner cannot accidentally repoint a directory.
Plugins: Four Tiers, and One That Is Not On The Web
Both systems have more than one kind of plugin. The tiers line up loosely, and then diverge on one point that matters.
WordPress loads them in this order, from wp-settings.php:
- Must-use — everything in
wp-content/mu-plugins/, loaded unconditionally. No activation, and no way to deactivate one from the admin. - Network-activated — multisite only.
- Active —
wp-content/plugins/, loaded from theactive_pluginsoption. Which is to say: the database decides which plugins run.
Djebel loads four, in this order:
- System plugins —
dj-content/system_plugins/. The closest thing to must-use. - Shared plugins — one directory, outside the site, for plugins that run on every site on that server. There is no WordPress equivalent outside multisite.
- Non-public plugins — a plugins directory inside the private tree, outside the document root.
- Regular plugins —
dj-content/plugins/.
Two differences worth pulling out.
What decides that a plugin runs. In WordPress it is a database row — the active_plugins option. In Djebel it is the filesystem: the plugin is in a directory that gets loaded, or it is not. That makes "which plugins are running" a question you answer with ls and a diff, and it makes activation state something git tracks. It also means there is no admin toggle, because there is no admin. If you want a plugin off, you move it or you filter it out — every stage is filterable: app.core.plugins.load_shared_plugins, app.core.plugins.load_plugins, app.core.plugins.plugin_dirs.
A plugin directory that is not web-reachable. This is the one with no WordPress counterpart. wp-content lives under the document root — that is what makes plugins_url() work, and it is also why a misconfigured server can serve a plugin's PHP as text. Djebel's non-public plugins dir sits outside the document root entirely. Nothing there is addressable over HTTP.
That has a consequence the assets system has to handle: a plugin with no web-reachable directory has no URL to build. So when such a plugin registers a CSS or JS file, Djebel inlines the file's contents into the page instead of emitting a <link> or <script src>. The plugin author writes the same one-line register() call either way and does not have to know where their plugin was installed.
Conditional loading — a plugin that only loads on the pages that need it
This one is a config line rather than an architecture, and it is the difference you would feel first.
Most plugins are needed on a handful of URLs and loaded on all of them. A contact form plugin parses its config, registers its hooks and its shortcode on your home page, your blog index, and every article — to do nothing on all of them.
Djebel lets a plugin declare where it is wanted:
[plugins]
djebel-contact.load_if_url = /contact|/support
The value is a |-separated list, each entry matched as a substring of the request URL — not a regex, not a glob. If none match, the plugin is marked inactive and never loads: no file read, no hooks registered, nothing to unregister later. Cheapest possible outcome, because the work never starts.
The substring matching is worth knowing before you rely on it. /contact matches /contact, /contact-us and /blog/contacting-people alike. That is fine for the common case and a trap if your URLs overlap, so pick patterns that are actually distinctive.
WordPress can do this too — you just write it yourself. The active plugin list comes from get_option( 'active_plugins' ), and that goes through the standard option_active_plugins filter, so an mu-plugin can inspect the request and unset plugins before they load. That is exactly how the plugin-organiser plugins in the directory work. The difference is not capability, it is that one is a line in a config file and the other is code you write and maintain.
Public vs private plugins
Djebel also splits plugins by intent, and encodes it in the name rather than a header field:
| Public (distributable) | Private (site-specific) | |
|---|---|---|
| Directory | djebel-<name> |
djebel-site-<name> |
| Global identifiers | djebel-plugin-<name> |
djebel-site-plugin-<name> |
| Where it lives | Its own git repo, pulled into each site as a submodule | Inside that one site's repo |
The site- segment is the declaration — there is no private: true to set and forget. It shows up in the directory listing, in the CSS class names and in the PHP class name, so a plugin that was never meant to be distributed is obvious at a glance in three places.
A distributable plugin never lives inside a site's repo, and never inside the framework — the core gains no feature code, ever. Fix it once in the plugin repo, and each site picks the fix up when it bumps its submodule pointer, which means each site pins a known-good version rather than being upgraded underneath you.
WordPress has no equivalent convention. Site-specific code tends to end up in mu-plugins or in the theme's functions.php, both of which work, and neither of which announces that the code is not meant to travel.
Headless
Both can run headless. The difference is how you ask.
WordPress: wp-blog-header.php loads the environment, runs the query, then includes template-loader.php, and the very first line of that file is if ( wp_using_themes() ). That is driven by the WP_USE_THEMES constant — which index.php defines as true — and it is filterable. Load wp-load.php directly instead of going through index.php and you get the whole WordPress environment with no template rendering. That is what most "headless WordPress" setups are doing, alongside the REST API.
Djebel: app.core.headless is a config toggle, default off. Turn it on and the bootstrap loads config, plugins, shortcodes and hooks, then returns before any theme or page output. Shutdown hooks still run.
So this is not "WordPress can't." Both arrive at the same place. The differences are smaller than they look:
- In Djebel it is a config value, so it can be set per environment without touching a PHP file or picking a different entry point.
- Djebel is headless without a database, because it never needed one. Headless WordPress is still WordPress: the full stack boots, including the DB connection.
- Djebel's asset queue defaults to off in headless mode, since a queue nothing renders is pure overhead — with an override, because the test suite runs headless and still has to exercise it.
If you are building an API, the honest summary is that WordPress gives you a mature REST layer you did not write, and Djebel gives you a smaller thing to boot. Which of those matters more depends entirely on whether you were going to use the REST layer.
Assets: The Row Most Worth Getting Right
The short version of this row is "one call instead of a hook," and the short version is not quite honest. Here is the accurate one.
In WordPress you enqueue:
add_action( 'wp_enqueue_scripts', 'my_plugin_assets' );
function my_plugin_assets() {
wp_enqueue_script(
'my-plugin',
plugins_url( 'assets/main.js', __FILE__ ),
array(),
'1.4.2',
true
);
}
In Djebel you register:
Dj_App_Assets::register([ 'plugin' => 'my-plugin', 'file' => '/assets/main.js', ]);
Two corrections to the folklore, both worth stating because they get repeated wrongly:
Enqueuing too early is not silent, and it is not fatal. WordPress calls _wp_scripts_maybe_doing_it_wrong() on every enqueue. If none of init, wp_enqueue_scripts, admin_enqueue_scripts or login_enqueue_scripts has fired yet, you get a _doing_it_wrong() notice telling you so. The script is still registered and still prints. The notice is only visible with WP_DEBUG on, so in production it is quiet — but the check exists, and "WordPress silently drops it" is wrong.
Enqueuing too late is the silent case. Once init has fired, that check returns immediately and never looks again. Enqueue a stylesheet after wp_head has already printed and nothing warns you — it simply is not on the page. That is the failure mode people actually hit, and it is a timing problem, not an API problem.
Djebel sidesteps the timing question rather than solving it better: the queue is assembled first and rendered when the page is built, so "before the page is assembled" is the only deadline. That is a smaller promise than it sounds — it works because there is one rendering path to hook, not an admin, a login screen and a front end with separate ones.
Shared Libraries Belong To The Site, Not To A Plugin
Here is a problem both systems have to answer. Four screens need the same date picker. Who registers it?
Whichever screen happened to need it first ends up owning the registration for all the others, and every screen added afterwards has to know to ask that one. It is arbitrary, and it goes wrong the day that first screen is removed.
WordPress's answer is handles, and it is a good one. Core bundles a set of shared libraries and registers them itself — jQuery is registered as version 3.7.1 in script-loader.php, along with a long list of others. A plugin never registers jQuery; it names it as a dependency and enqueues:
wp_enqueue_script( 'my-plugin', $src, array( 'jquery' ), '1.0', true );
Four plugins doing that get one jQuery. That is genuinely well solved, and it is why the WordPress ecosystem does not drown in duplicate copies of the same library.
The limit is that it works for what core ships. A library core does not bundle — a date picker, a charting library, your own shared stylesheet — still needs somebody to register it, and you are back to picking a plugin to carry it, or writing an mu-plugin whose only job is to register things.
Djebel's answer is that the site declares them, in config. Since 2026-08-25 there is an [assets] section in app.ini:
[assets]
jquery.file = /site/shared/jquery/jquery.min.js
select2_css.file = /site/shared/select2/select2.min.css
select2.file = /site/shared/select2/select2.min.js
select2.prereq = jquery
The section key is the asset id, and everything under it is a parameter register() already accepts — so it grows by whatever the asset API grows by and has no vocabulary of its own. These are registered before the first plugin runs, which produces two properties worth naming:
- Registering first is what keeps them overridable. Re-registering an id replaces that entry in place, so a plugin can still swap the site's jQuery for its own, and everything that named it as a prerequisite follows the replacement. Config is the base layer, not the last word.
prereqorders them, not the order you typed them. Config is read top to bottom, so without it the render order would be an accident of editing.
Note that no library is bundled. Djebel ships no jQuery, so [assets] is the mechanism and you supply the file. WordPress ships the file and the mechanism, for its own list.
The cost, and it is a real one: anything declared this way is on every page, including the ones that never use it. A login screen now carries jQuery because some other screen needed it. WordPress's dependency model does not have that problem — a handle is only fetched by a page that enqueues something depending on it. If Djebel needs an opt-in mode later, the natural shape is one optional key whose absence means today's behaviour.
Cache Busting
WordPress's $ver argument is the fourth parameter of both wp_enqueue_script() and wp_enqueue_style(). What is worth knowing is the default: if you leave $ver alone, WordPress uses get_bloginfo( 'version' ) — the WordPress version, not your file's. So main.js?ver=7.1 changes when WordPress updates and not when you edit the file. Pass null and no version is appended at all.
That is not a bug; it is a sensible default for core's own scripts, which do ship with WordPress. It just means plugin authors have to supply their own version and keep it current, and a stale $ver after a deploy is a genuinely common bug.
Djebel stamps ?v=<filemtime> from the file itself, so editing the file changes the URL. You can pass an explicit version — a build id or release tag — and when you do, the file is never stat'ed for one.
Minified Builds - serve minified js and css versions automatically.
Nearly every project ends up with main.js and main.min.js, and then with a line somewhere that picks between them.
WordPress has a mechanism for this, and it is aimed at core. SCRIPT_DEBUG defaults to false — it is only true for -src development builds of WordPress itself — and wp_scripts_get_suffix() returns '.min' when it is off, '' when it is on. Core uses that for its own bundled scripts and styles.
A plugin can call the same helper, but it does the rest itself:
$suffix = wp_scripts_get_suffix();
wp_enqueue_script( 'my-plugin', plugins_url( "assets/main{$suffix}.js", __FILE__ ), array(), '1.4.2', true );
Two things about that are worth noticing. It is a constant you set in wp-config.php, not something derived from the environment. And the suffix is applied unconditionally — if main.min.js is not there, you have just enqueued a 404.
Djebel does the check itself, on every registered asset. Once a file resolves, it looks for a .min build sitting beside it and uses that instead when there is one. You register the source and nothing else:
Dj_App_Assets::register([ 'plugin' => 'my-plugin', 'file' => '/assets/main.js', ]);
The default follows the environment rather than a constant: Dj_App_Env::isLive() decides, so development gets the source and everything else — staging included — prefers the build. app.core.assets.use_min overrides that, and a filter gets the last word.
Four details make it safe to leave on:
- It falls back. No
.minbeside the file means you get the file. Shipping without a build step is not a broken site, it is an unminified one. - It costs one stat, on a file already found — not a second scan of every candidate directory, which would be a syscall per miss on every site that ships no builds at all.
- It only probes CSS and JS. Anything else is not a thing that has a minified sibling, so it never reaches the filesystem check.
- It will not double-apply. A file already named
main.min.jsis left alone, and the four characters are read only where the marker would sit — so a directory named that way cannot pass a source file off as a build.
The checks are ordered so the ones that reject in bulk come first: the site-wide switch, then the per-asset opt-out, and only then anything that inspects the filename or touches disk. On a site with minification off, every asset stops on the first line having done no work at all.
The escape hatch is per asset
A site-wide switch is the wrong granularity for the case that actually comes up: one file that must not be swapped, while everything else keeps using builds. A vendor bundle that happens to sit next to an unrelated .min. Something you are debugging in production at 2am.
Dj_App_Assets::register([
'plugin' => 'my-plugin',
'file' => '/assets/main.js',
'skip_min' => 1,
]);
That asset serves the file it named. The rest of the site is untouched.
So there are three levels, narrowing as they go: app.core.assets.use_min for the site, the app.core.assets.filter.use_min filter for whoever wants to decide per request or per asset, and skip_min on the one registration that has a reason. The environment check behind the default is memoized for the request — it cannot change between two assets and the env scan is the expensive half — but the filter still runs per call, so one registered after assets were already queued is honoured just the same.
WordPress's SCRIPT_DEBUG has one level: on or off, for the whole site, set in a file you have to deploy.
Asset URLs
WordPress gives you the pieces: plugins_url(), get_template_directory_uri(), get_stylesheet_directory_uri(). You assemble the URL and pass it in.
Djebel resolves it from context — you name the plugin or theme and the relative file, and it works out the rest. The part with no WordPress equivalent: if the plugin was installed somewhere not reachable over the web, there is no URL to build, so Djebel inlines the file's contents instead. The plugin author does not have to know where their plugin was installed. There is more detail in Registering Assets in Djebel.
Hooks
This row is a genuine tie, and it is worth saying so.
Both use actions and filters, and the concept transfers directly — if you have written WordPress hooks you already know how Djebel's work. Djebel's live in Dj_App_Hooks with slightly different naming, and hook names normalise . and / to the same thing, so app.page.content and app/page/content are one hook. The mental model is the same.
Djebel did not improve on this. It borrowed it, because it was right.
The Whole Page Is A Filter
Djebel fires app.page.full_content with the entire rendered page as the value. Not the post content, not a template part — the whole document, before it goes out.
Core rides that hook itself rather than reserving it: shortcode replacement is a listener on it, so is injectBodyClasses at priority 100, and so is the seam injector at priority 125. That is worth noticing — the extension point is not a courtesy bolted on for plugins, it is the mechanism core uses, which means it cannot quietly rot.
If you want to rewrite every asset URL on the page, inject something no template anticipated, or post-process markup a theme produced and you cannot edit, you hook one filter and you have the buffer.
WordPress can do this, and there is no core hook for it. Both halves matter. There is no filter over the finished document in core — the standard technique is to call ob_start() yourself early (on init or template_redirect) and process the buffer on shutdown. Plenty of plugins do exactly that, and it works. But every plugin doing it starts its own buffer, they nest, and their order depends on which plugin loaded first. A shared filter with priorities is a coordination mechanism; competing ob_start() calls are not.
The trade is the obvious one: a hook that hands every listener the entire page is a hook that lets one bad listener corrupt the entire page. Djebel takes that bet because the alternative — everyone buffering independently — has the same failure mode and no ordering.
Libraries
WordPress plugins bundle what they need, which is why four plugins can ship four copies of the same library and load all four.
Djebel has Dj_App_Lib::loadLib() for shared libraries, loaded lazily or eagerly or by pattern. Covered properly in How Djebel Loads Libraries.
Deploying and Updating
This is the difference with the most day-to-day consequence, and the table row for it — "copy files" — undersells it badly.
The framework is not part of your site
In WordPress, core lives inside the document root. wp-admin/ and wp-includes/ sit next to wp-content/, one tree, deployed together. Core can update itself in place — WP_AUTO_UPDATE_CORE and per-plugin auto-updates exist precisely so a site stays current without anyone logging in.
In Djebel the site does not contain the framework at all. A site's index.php is a loader, and the framework it points at is a standalone copy living outside the site tree — typically a sibling directory one level above the document root, or a .phar. The resolution order is: a DJEBEL_APP_PKG environment variable naming a .phar, then .ht_djebel/app/djebel-app.phar, then a sibling djebel-app/ directory, then a dev checkout.
Three things follow from that:
- The framework upgrades independently of the site. Swap that directory or drop in a new
.phar, and the site repo does not change — no commit, no diff, nothing to review. - Several sites can share one copy, or each can pin its own. That is a per-deployment decision, not a property of the framework.
- Core is not in the document root, so no request can reach a framework file directly, whatever the server is misconfigured to do.
The cost is honest: nothing updates itself. There is no "update available" banner, because there is no admin to show one. Staying current is your job or your deploy script's.
The site is a git repo; the parts are pinned
Plugins and themes are git submodules pinned to a specific commit. Deploying is:
git pull
git submodule update --init --recursive
That second line lands every submodule on its pinned commit — deterministic, never "latest." Two consequences worth spelling out:
Every site pins independently. A shared plugin does not force every site onto the same version. A stable production site can sit on a commit from three months ago while another rides ahead, and neither one is holding the other back. Updating is an explicit act: bump the pin, commit that, deploy.
A fix is two commits. One in the plugin's own repo, one in the site repo recording the moved pointer. That feels like ceremony until the first time a plugin update breaks a site and you can see, in git log, exactly which commit moved and when.
WordPress's model is the mirror image, and it is a real trade rather than a worse one. Auto-updates mean a security fix reaches a site nobody is watching — which describes an enormous number of WordPress sites, and is a genuinely good outcome for them. Pinning means a security fix reaches a site when someone bumps the pin, which is safer for a site under active development and worse for one that has been left alone.
If nobody is minding the site, WordPress's default is the right one. If someone is, pinning gives you a deploy you can reason about.
The Honest Part
WordPress's ecosystem is the real reason most people choose it, and nothing above changes that. Tens of thousands of plugins, a theme for every niche, hosting built specifically for it, and a pool of developers who already know it. If what you need exists as a WordPress plugin, building it on anything else is work you did not have to do.
Djebel does not have that, and it will not have it soon. What it has is a smaller surface: no database to administer, no schema to migrate, no admin panel to secure, and content you can grep.
That is the trade. If you want a marketplace and an admin UI, WordPress is not the compromise — it is the answer. If you want a site that is files and a request, and you were going to keep the content in git anyway, the database was overhead you were paying without using.
Two different assumptions. Pick the one that matches what you are building.
WordPress behaviour in this post was verified against WordPress 7.1 source. Function names, hook names and defaults do move between versions — if you are on a different one, check before relying on the details.