A lightweight, modular, and typed framework for building modern WordPress applications.
- 🔌 Dependency injection — Autowiring, autoconfiguration, and service container
- 🚀 Flexible architecture — Choose between configuration-driven or standalone mode
- 🧩 Extensible by design — Build and compose reusable, isolated features with bundles
- 🪶 Lightweight kernel — Minimal core with a small, focused footprint
- ⚡️ Type-safe & modern PHP — Strict typing and modern PHP practices
- 🔄 Works everywhere — Compatible with themes, plugins, and mu-plugins
- 🛠️ Developer-friendly API — Simple helpers for accessing services, parameters, and the application container
requirements:
- PHP: 8.5+
command:
composer require offsetwp/frameworkAdd symfony/yaml too if you want to write services.yaml / packages/*.yaml instead of PHP:
composer require symfony/yamlOtherwise loading a YAML file fails with Unable to load YAML config files as the Symfony Yaml Component is not installed.
The framework can work in two modes "Configuration" and "Standalone" :
Configuration modeis a Symfony-like dependency injection mode that loads a fullconfig/directory (recommended for structured projects).Standalone modeis a minimal mode where you register one or a few services directly (recommended for small themes, mu-plugins, prototypes).
// functions.php or my-mu-plugin.php or my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use OffsetWP\Framework\Kernel;
use OffsetWP\Support\Env;
$kernel = Kernel::configure( __DIR__ )
->environment( Env::type() )
->debug( Env::isDebug() )
->config( __DIR__ . '/config' )
->boot();
// Register it so the app() helper can find it later
app( 'app', $kernel );// config/services.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function ( ContainerConfigurator $container ): void {
$services = $container->services();
// Configure services
$services
->defaults()
->autowire()
->autoconfigure()
->public();
// Set container global variables
$container->parameters()
->set( 'app.name', get_bloginfo( 'name' ) )
->set( 'app.description', get_bloginfo( 'description' ) )
->set( 'app.url', get_site_url() );
/**
* Register in container services the "App\**\*" classes from the "app/" folder
* located next to "config/". Relative resources are resolved from the
* directory of the current config file, hence "../app/"
* and auto call the "__construct()" method
*/
$services
->load( 'App\\', '../app/' )
->exclude( '../app/Application.php' ) // never register the kernel itself as a service
->tag( 'kernel.autoload' );
};About the
kernel.autoloadtag — services carrying it are instantiated eagerly by the kernel's compiler pass while the container compiles, i.e. before anyBundle::boot()runs. Use it for classes whose constructor registers WordPress hooks; do not use it for services that expect a bundle to have booted first. The tag order is not configurable.
// config/bundles.php
return array(
\OffsetWP\Bundle\DemoBundle\DemoBundle::class => array( 'all' => true ), // all environment
);Configuration mode — use this when your project is organized and you want the full power of a DI container (autowiring, bundles, environment-specific config). Pass the path to a config/ folder that contains:
services.phporservices.yaml— service definitions (required)bundles.php— optional list of bundles to register (each bundle can add its own DI extension); skipped when the file is absentpackages/*— optional per-package configuration files (PHP or YAML) that are loaded per environment
The kernel will scan and import files from the config/ directory similarly to Symfony: global services.*, packages/*, and environment-specific overrides.
my-theme/ # root
├─ app/ # your "App\*" classes, loaded with '../app/'
├─ config/
│ ├─ packages/
│ │ └─ demo.php # demo bundle configuration (.php|.yaml)
│ ├─ bundles.php # bundle register
│ └─ services.php # services register (.php|.yaml)
├─ functions.php
mu-plugins/
├─ my-mu-plugin/ # root
│ ├─ app/ # your "App\*" classes, loaded with '../app/'
│ ├─ config/
│ │ ├─ packages/
│ │ │ └─ demo.php # demo bundle configuration (.php|.yaml)
│ │ ├─ bundles.php # bundle register
│ │ ├─ services.php # services register (.php|.yaml)
├─ my-mu-plugin.php
my-plugin/ # root
├─ app/ # your "App\*" classes, loaded with '../app/'
├─ config/
│ ├─ packages/
│ │ └─ demo.php # demo bundle configuration (.php|.yaml)
│ ├─ bundles.php # bundle register
│ └─ services.php # services register (.php|.yaml)
├─ my-plugin.php
// functions.php or my-mu-plugin.php or my-plugin.php
require_once __DIR__ . '/vendor/autoload.php';
use OffsetWP\Framework\Kernel;
use OffsetWP\Support\Env;
$kernel = Kernel::configure( __DIR__ )
->environment( Env::type() )
->debug( Env::isDebug() )
->services( __DIR__ . '/services.php' )
->boot();
// Get and use "MyService" instance
$my_service = $kernel->service( App\Service\MyService::class );Standalone mode — use ->services( $file ) when you only need a few services and do not want the kernel to scan bundles or packages/. The single services.php file can use the Symfony dependency injection PHP configurator API (ContainerConfigurator) and behaves like a regular services.php but without bundle discovery.
my-theme/
├─ services.php # services register (.php|.yaml)
├─ functions.php
You can extend the OffsetWP\Framework\Kernel class to better organize your projects, for example:
Application: for the root of your projectMyPlugin: to better organize your pluginMyTheme: to create a modern theme
// my-theme/app/Application.php
namespace App;
use OffsetWP\Framework\Kernel;
use OffsetWP\Support\Env;
final class Application extends Kernel {
protected string $environment = Env::DEVELOPMENT;
protected bool $is_debug = true;
protected string $services_path = __DIR__ . '/../services.php'; // __DIR__ is "app/", so go up one level to reach the project root.
}// my-theme/functions.php — instantiate from the project root, not from app/
require_once __DIR__ . '/vendor/autoload.php';
$kernel = new \App\Application( __DIR__ )->boot();
app( 'app', $kernel );Overriding
$config_path/$services_pathas properties bypassessetConfigPath()/setServicesPath(), so the path is not validated. A wrong path surfaces later as aFileLocatorFileNotFoundExceptionduring boot instead of the explicitRuntimeException: The services file does not exist.PreferKernel::configure( … )->services( … )unless you really need a subclass.
use OffsetWP\Framework\Kernel;
$kernel = Kernel::configure( __DIR__ )
->services( __DIR__ . '/services.php' )
->boot();
// Set and get instances
app( 'app', $kernel ); // Register the application instance
echo app()->environment(); // Get the application instance and display the environment type
instance( MyTheme::class, new MyTheme() ); // Register a instance
instance( MyTheme::class )->doSomething(); // Get a instance
// Get service
app()->service( 'myservice' )->doSomething(); // with alias
app()->service( \App\Service\MyService::class )->doSomething(); // with classname
app()->hasService( 'myservice' );
// Get parameter
app()->parameter( 'kernel.root_path' );
app()->hasParameter( 'kernel.is_debug' );A name can be registered once. Registering it twice throws LogicException: A instance is already registered under "app"., and reading an unknown name throws LogicException: No instance registered under "app". — so call app( 'app', $kernel ) exactly once, at boot, before any app() call.
Parameters exposed by the kernel:
| Parameter | Value |
|---|---|
kernel.root_path |
resolved root path passed to Kernel::configure() |
kernel.environment |
value given to ->environment() |
kernel.is_debug |
value given to ->debug() |
kernel.charset |
UTF-8 unless overridden |
kernel.bundles |
name => class of registered bundles |
kernel.bundles_metadata |
name => [ path, namespace ] |
kernel.build_dir |
APP_BUILD_PATH, else APP_CACHE_PATH, else <root>/var/cache/<env> |
use OffsetWP\Support\Env;
// Basic
Env::has( 'DB_HOST' ); // Check if a environment variable exist
Env::raw( 'DB_HOST' ); // Get raw environment variable, '' if not set
Env::raw( 'DB_HOST', 'localhost' ); // Get raw environment variable with a default
Env::get( 'MY_VARIABLE' ); // Get casted environment variable (null, boolean, integer, float, json, array, string)
Env::get( 'MY_VARIABLE', 'localhost' ); // Get variable, if not exist, set a default value
// Casted
Env::string( 'DB_HOST' );
Env::integer( 'WP_POST_REVISIONS' );
Env::float( 'MY_RATIO' );
Env::boolean( 'APP_MAINTENANCE' );
Env::array( 'MY_ARRAY' );
Env::array( 'MY_ARRAY', '|' ); // With custom separator
Env::json( 'MY_JSON' );
// Environment
Env::type(); // 'local', 'development', 'staging' or 'production'
Env::isLocal();
Env::isDevelopment();
Env::isStaging();
Env::isProduction();
Env::isDebug();Bundles are small, reusable packages that can extend the kernel by registering services, compiler passes and configuration. The kernel discovers bundles listed in config/bundles.php and, when using configuration mode, will register each bundle's container extension so it can load bundle-specific configuration from config/packages/*.
There are two common bundle flavors:
- Simple bundle — no configuration required, just a class that can register services or hooks in
boot(). - Configurable bundle — exposes configuration and a DI extension so the host application can configure the bundle via
config/packages/{bundle}.phporconfig/packages/{env}/{bundle}.php.
demo-bundle/
├─ src/
│ └─ DemoBundle.php
└─ composer.json
// src/DemoBundle.php
namespace JohnDoe\Bundle\DemoBundle;
use OffsetWP\Framework\Bundle\Bundle;
final class DemoBundle extends Bundle {
public function boot(): void {
// register hooks or perform runtime initialization
}
}composer.json:
{
"name": "johndoe/demo-bundle",
"type": "library",
"autoload": {
"psr-4": {
"JohnDoe\\Bundle\\DemoBundle\\": "src/"
}
},
"minimum-stability": "stable",
"prefer-stable": true,
"require": {
"offsetwp/framework": "^1.0"
}
}Register the bundle in the application config/bundles.php:
return array(
\JohnDoe\Bundle\DemoBundle\DemoBundle::class => array( 'all' => true ),
);Here is a minimal example showing how to create a configurable bundle that queries the GitHub API using Guzzle.
- Create the
GithubBundlebundle (namespaceJohnDoe\\Bundle\\GithubBundle). The bundle exposes three configurable options:
enabled(bool): Enable/disable the bundleapi_base_url(string): base URL for requests to the GitHub APItoken(string|null): personal GitHub token (optional)
composer.json :
{
"name": "johndoe/github-bundle",
"type": "library",
"autoload": {
"psr-4": {
"JohnDoe\\Bundle\\GithubBundle\\": "src/"
}
},
"require": {
"offsetwp/framework": "^1.0",
"guzzlehttp/guzzle": "^7.0 || ^8.0"
},
"minimum-stability": "stable",
"prefer-stable": true
}// src/GithubBundle.php
namespace JohnDoe\Bundle\GithubBundle;
use OffsetWP\Framework\Bundle\Bundle;
use Symfony\Component\Config\Definition\Builder\ArrayNodeDefinition;
use Symfony\Component\Config\Definition\Configurator\DefinitionConfigurator;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class GithubBundle extends Bundle {
public function configure( DefinitionConfigurator $definition ): void {
/**
* The bundle config definition
*
* @var ArrayNodeDefinition $root
*/
$root = $definition->rootNode();
$root
->children()
->booleanNode( 'enabled' )->defaultTrue()->end()
->scalarNode( 'api_base_url' )->defaultValue( 'https://api.github.com' )->end()
->scalarNode( 'token' )->defaultNull()->end()
->end();
}
public function loadExtension( array $config, ContainerConfigurator $container, ContainerBuilder $builder ): void {
if ( ! $config['enabled'] ) {
return;
}
$builder->setParameter( 'github.enabled', $config['enabled'] );
$builder->setParameter( 'github.api_base_url', $config['api_base_url'] );
$builder->setParameter( 'github.token', $config['token'] );
$container->import( __DIR__ . '/Resources/config/services.php' );
}
}- Example of a main service
GithubClient(usesguzzlehttp/guzzle):
// src/Service/GithubClient.php
namespace JohnDoe\Bundle\GithubBundle\Service;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
final class GithubClient {
private ClientInterface $http;
public function __construct( private string $base_url, private ?string $token = null ) {
$headers = array();
if ( $this->token ) {
$headers['Authorization'] = 'token ' . $this->token;
$headers['Accept'] = 'application/vnd.github.v3+json';
}
$this->http = new Client(
array(
'base_uri' => $this->base_url,
'headers' => $headers,
)
);
}
public function repoInfo( string $owner, string $repo ): array {
$response = $this->http->request( 'GET', sprintf( '/repos/%s/%s', $owner, $repo ) );
$content = $response->getBody()->getContents();
return json_decode( $content, true ) ?: array();
}
}- The
services.phpfile in the bundle imports the settings provided by the configuration:
// src/Resources/config/services.php
use JohnDoe\Bundle\GithubBundle\Service\GithubClient;
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function ( ContainerConfigurator $configurator ) {
$services = $configurator->services();
$services->set( GithubClient::class )
->arg( '$base_url', '%github.api_base_url%' )
->arg( '$token', '%github.token%' )
->public();
// Create a service alias.
$services->alias( 'github', GithubClient::class )
->public();
};- Save the bundle in
config/bundles.php:
// config/bundles.php
return array(
\JohnDoe\Bundle\GithubBundle\GithubBundle::class => array( 'all' => true ),
);- Configure the bundle in
config/packages/github.php(orpackages/, depending on your organization):
// config/packages/github.php
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return function ( ContainerConfigurator $configurator ) {
// Feed the bundle's configuration tree — this is what reaches loadExtension().
$configurator->extension( 'github', array(
'enabled' => true,
'api_base_url' => 'https://api.github.com',
'token' => '%env(GITHUB_TOKEN)%', // your generated Github API token
) );
};- Using it in the application — both the class id and the alias resolve, since step 3 made each of them public:
$github = app()->service( 'github' ); // find service from alias
$github = app()->service( \JohnDoe\Bundle\GithubBundle\Service\GithubClient::class ); // or by classname
var_dump( $github->repoInfo( 'offsetwp', 'framework' ) );
