Modern WordPress framework with Symfony Dependency Injection and attribute-based hooks.
- Symfony DI Container - Full-featured dependency injection with autowiring
- Attribute-Based Hooks - Define WordPress hooks using PHP 8.4 attributes
- Attribute-Based Shortcodes - Clean shortcode definitions with automatic parameter mapping
- Templating System - PHTML and Twig template engines with theme override support
- Template Functions - Define template helpers with dependency injection using attributes
- Service Discovery - Automatic service registration from configured directories
- Container Caching - Compiled container with file-based cache invalidation
- Bundle System - Support for parent/child themes and plugin bundles
- PHP & YAML Config - Flexible configuration options
- Type-Safe - Full PHP 8.4 type safety
- PHP 8.4 or higher
- WordPress 6.0 or higher
- Composer
Install via Composer:
composer require saij/wpapp1. Create your plugin structure:
my-plugin/
├── src/
│ ├── Plugin.php
│ ├── Services/
│ │ ├── ExampleService.php
│ │ └── TemplateHelpers.php
│ └── Templates/
│ └── Welcome.phtml
├── config/
│ └── services.yaml
├── composer.json
└── my-plugin.php
2. Define your plugin class (src/Plugin.php):
<?php
namespace MyPlugin;
final class Plugin extends \WPApp\Application
{
}3. Boot your plugin (my-plugin.php):
<?php
/*
Plugin Name: My Plugin
*/
require_once __DIR__ . '/vendor/autoload.php';
MyPlugin\Plugin::boot(__DIR__);4. Configure services (config/services.yaml):
services:
_defaults:
autowire: true
autoconfigure: true
MyPlugin\:
resource: '../src/*'5. Create a service with template rendering (src/Services/ExampleService.php):
<?php
namespace MyPlugin\Services;
use WPApp\Hook\Hook;
use WPApp\Template\TemplateRenderer;
class ExampleService
{
public function __construct(
private readonly TemplateRenderer $renderer,
) {}
#[Hook('the_content')]
public function addWelcomeMessage(string $content): string
{
$welcome = $this->renderer->render('@MyPlugin/Templates/Welcome', [
'username' => wp_get_current_user()->display_name,
]);
return $welcome . $content;
}
}6. Create template helpers (src/Services/TemplateHelpers.php):
<?php
namespace MyPlugin\Services;
use WPApp\Template\TemplateFunction;
class TemplateHelpers
{
#[TemplateFunction('greeting')]
public function greeting(string $name): string
{
$hour = (int) date('G');
$timeOfDay = match (true) {
$hour < 12 => 'morning',
$hour < 18 => 'afternoon',
default => 'evening',
};
return "Good {$timeOfDay}, {$name}!";
}
}7. Create a template (src/Templates/Welcome.phtml):
<div class="welcome-message">
<h3><?= $this->greeting($this->username) ?></h3>
<p>Welcome back to our site!</p>
</div>Theme structure:
my-theme/
├── src/
│ ├── Theme.php
│ └── Services/
│ └── ThemeService.php
├── config/
│ └── services.yaml
├── composer.json
└── functions.php
Define your theme class (src/Theme.php):
<?php
namespace MyTheme;
final class Theme extends \WPApp\Application
{
}Boot your theme (functions.php):
<?php
require_once __DIR__ . '/vendor/autoload.php';
MyTheme\Theme::boot(__DIR__);Define hooks using the #[Hook] attribute:
<?php
namespace MyPlugin\Services;
use WPApp\Hook\Hook;
class ExampleService
{
// Simple action hook
#[Hook('init')]
public function onInit(): void
{
// Your code here
}
// Filter hook with WordPress parameters
#[Hook('the_content', priority: 20)]
public function filterContent(string $content): string
{
return $content . '<p>Added by my plugin!</p>';
}
// Hook with service injection
#[Hook('wp_enqueue_scripts')]
public function enqueueScripts(AssetService $assets): void
{
$assets->enqueueStyle('my-style');
}
// Mix service injection with WordPress parameters
#[Hook('the_title', priority: 10)]
public function filterTitle(LoggerService $logger, string $title, int $postId): string
{
$logger->log("Processing title for post {$postId}");
return strtoupper($title);
}
}Multiple hooks on same method:
#[Hook('save_post')]
#[Hook('publish_post')]
public function onPostSave(int $postId): void
{
// Runs on both hooks
}Define shortcodes using the #[Shortcode] attribute:
<?php
namespace MyPlugin\Services;
use WPApp\Shortcode\Shortcode;
class GalleryShortcode
{
#[Shortcode('gallery')]
public function render(
ImageService $images, // Service injection
string $content = '', // Shortcode content
int $columns = 3, // Attribute: columns="3"
bool $showTitles = false, // Attribute: show_titles="true"
string $size = 'medium' // Attribute: size="medium"
): string {
$gallery = $images->createGallery($columns, $size, $showTitles);
return $gallery->render($content);
}
}Usage in content:
[gallery columns="4" show_titles="true" size="large"]
Gallery content here
[/gallery]
WPApp provides a powerful templating system with PHTML and Twig engines, dependency injection for template functions, and theme override support.
Inject TemplateRenderer into any service to render templates:
<?php
namespace MyPlugin\Services;
use WPApp\Template\TemplateRenderer;
class ProductService
{
public function __construct(
private readonly TemplateRenderer $renderer,
) {}
public function renderProduct(int $productId): string
{
$product = $this->getProduct($productId);
return $this->renderer->render(
'@MyPlugin/Products/Single',
['product' => $product]
);
}
}Define reusable template functions with dependency injection using the #[TemplateFunction] attribute:
<?php
namespace MyPlugin\Services;
use WPApp\Template\TemplateFunction;
class TemplateHelpers
{
#[TemplateFunction('format_price')]
public function formatPrice(
CurrencyService $currency, // Service injection
float $amount // Template argument
): string {
return $currency->format($amount);
}
#[TemplateFunction('user_avatar')]
public function renderAvatar(
AvatarService $avatarService,
int $userId,
int $size = 48
): string {
return $avatarService->render($userId, $size);
}
}PHTML templates use plain PHP with $this context for accessing variables and functions:
Template: src/Products/Single.phtml
<div class="product">
<h2><?= $this->title ?></h2>
<div class="price">
<?= $this->format_price($this->price) ?>
</div>
<div class="description">
<?= $this->description ?>
</div>
<!-- Render sub-template using built-in partial() -->
<?= $this->partial('@MyPlugin/Products/Reviews', [
'productId' => $this->id
]) ?>
</div>Twig templates use Twig syntax with automatic template function registration:
Template: src/Products/Single.twig
<div class="product">
<h2>{{ title }}</h2>
<div class="price">
{{ format_price(price) }}
</div>
<div class="description">
{{ description }}
</div>
{# Render sub-template using built-in partial() #}
{{ partial('@MyPlugin/Products/Reviews', {
productId: id
}) }}
</div>Twig features:
- Automatic caching in
wp-content/cache/wpapp-twig/ - All template functions auto-registered
- Full Twig syntax support (filters, tests, etc.)
Templates use @Namespace/Path/To/Template syntax where:
- Namespace is your primary namespace from
composer.json(e.g.,MyPlugin) - Path is the relative path to the template file
- File extension (
.phtmlor.twig) is detected automatically
Example:
For plugin with primary namespace MyPlugin\:
- Template name:
@MyPlugin/Products/Single - Resolves to:
src/Products/Single.phtml(or.twig)
For theme with primary namespace MyTheme\:
- Template name:
@MyTheme/Components/Header - Resolves to:
src/Components/Header.phtml(or.twig)
Templates can be overridden by themes, searched in this order:
- Active theme:
wp-content/themes/child/templates/MyPlugin/Products/Single.phtml - Parent theme:
wp-content/themes/parent/templates/MyPlugin/Products/Single.phtml - Plugin/theme:
wp-content/plugins/my-plugin/src/Products/Single.phtml
This allows theme developers to customize plugin templates without modifying plugin files.
Example override structure:
wp-content/
├── themes/
│ └── my-theme/
│ └── templates/
│ └── MyPlugin/
│ └── Products/
│ └── Single.phtml # Theme overrides plugin template
└── plugins/
└── my-plugin/
└── src/
└── Products/
└── Single.phtml # Original plugin template
partial(template, context) - Render sub-templates:
// PHTML
<?= $this->partial('@MyPlugin/Components/Button', [
'text' => 'Click Me',
'url' => '/action'
]) ?>
// Twig
{{ partial('@MyPlugin/Components/Button', {
text: 'Click Me',
url: '/action'
}) }}Extend the templating system by implementing EngineInterface:
<?php
namespace MyPlugin\Template;
use WPApp\Template\EngineInterface;
class MarkdownEngine implements EngineInterface
{
public function getSupportedExtensions(): array
{
return ['md', 'markdown'];
}
public function render(string $path, array $context): string
{
$markdown = file_get_contents($path);
// Your markdown rendering logic here
return $this->parseMarkdown($markdown, $context);
}
}Place the file in your auto-discovered directory (e.g., src/Template/MarkdownEngine.php) and it's automatically discovered and registered. No manual configuration needed!
When a child theme or bundle service extends a parent service, the framework automatically handles inheritance at the service level to prevent duplicate registrations.
How it works:
- Parent services are automatically skipped during attribute discovery when a child service extends them
- Only the leaf service (most specific in the hierarchy) has its attributes analyzed
- Inherited methods with
#[Hook]or#[Shortcode]are discovered only once on the child service - Prevents duplicate hook execution and shortcode conflicts
Example:
// Parent theme
namespace ParentTheme\Services;
class WelcomeService {
#[Shortcode('welcome')]
public function render(string $name = 'Guest'): string {
return "Welcome {$name}!";
}
#[Hook('wp_footer')]
public function addFooter(): void {
echo '<p>Parent footer</p>';
}
}
// Child theme extends parent
namespace ChildTheme\Services;
class WelcomeService extends \ParentTheme\Services\WelcomeService {
// Inherits both render() and addFooter() methods
// Parent service is skipped during attribute discovery
// Only child service methods are analyzed
}Result: Parent service ParentTheme\Services\WelcomeService is completely skipped. The [welcome] shortcode and wp_footer hook are registered only once from the child service.
Important:
- Child service MUST extend parent service class using PHP
extendskeyword - Applies to parent/child themes, bundles, and any service inheritance
- Supports multi-level inheritance (grandparent → parent → child)
- With
WP_DEBUGenabled, framework logs skipped services
services:
_defaults:
autowire: true
autoconfigure: true
MyPlugin\:
resource: '../src/*'
exclude: '../src/{Kernel.php}'
# Explicit service definition
MyPlugin\Services\ApiClient:
arguments:
$apiKey: '%env(API_KEY)%'
$timeout: 30
parameters:
app.version: '1.0.0'
app.debug: false<?php
return [
'services' => [
'_defaults' => [
'autowire' => true,
'autoconfigure' => true,
],
'MyPlugin\\' => [
'resource' => '../src/*',
'exclude' => '../src/{Kernel.php}',
],
'MyPlugin\\Services\\ApiClient' => [
'arguments' => [
'$apiKey' => '%env(API_KEY)%',
'$timeout' => 30,
],
],
],
'parameters' => [
'app.version' => '1.0.0',
'app.debug' => false,
],
];WPApp automatically detects and supports parent/child theme relationships.
Parent theme:
// parent-theme/functions.php
ParentTheme\Theme::boot(__DIR__);Child theme:
// child-theme/functions.php
ChildTheme\Theme::boot(__DIR__);The child theme automatically:
- Loads parent theme services
- Merges parent and child configurations
- Can override parent services by using the same service class name
Service override example:
// Parent theme
namespace ParentTheme\Services;
class HeaderService {
public function render(): string {
return '<header>Parent Header</header>';
}
}
// Child theme (must extend parent)
namespace ChildTheme\Services;
use ParentTheme\Services\HeaderService as ParentHeaderService;
class HeaderService extends ParentHeaderService {
public function render(): string {
return '<header>Child Header</header>';
}
}Bundles provide modular architecture by packaging related services and configuration together. Each bundle is a class implementing BundleInterface.
Creating a bundle:
namespace MyPlugin\Bundles;
use WPApp\BundleInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
class AdminBundle implements BundleInterface
{
public function getPath(): string
{
return __DIR__;
}
public function configureContainer(ContainerBuilder $builder): void
{
// Optional: Register custom services, parameters, or compiler passes
$builder->setParameter('admin.menu_position', 50);
}
}Bundle directory structure:
bundles/admin/
├── config/
│ └── services.yaml # Bundle-specific services
├── Services/
│ ├── MenuService.php
│ └── SettingsService.php
└── AdminBundle.php # Bundle class
Registering bundles in config/services.yaml:
wpapp:
bundles:
- MyPlugin\Bundles\AdminBundle
- MyPlugin\Bundles\ApiBundle
services:
_defaults:
autowire: true
autoconfigure: true
MyPlugin\:
resource: '../src/*'Or in config/services.php:
return [
'wpapp' => [
'bundles' => [
MyPlugin\Bundles\AdminBundle::class,
MyPlugin\Bundles\ApiBundle::class,
],
],
'services' => [
// ... service configuration
],
];Bundles are automatically loaded during container compilation, and their services and configurations are merged into the main container.
Access the container from anywhere:
// Get container
$container = MyPlugin\Plugin::getContainer();
// Get service
$service = $container->get(MyService::class);class MyService
{
public function __construct(
private string $apiKey,
private int $timeout = 30
) {}
}services:
MyPlugin\Services\MyService:
arguments:
$apiKey: '%env(API_KEY)%'
$timeout: 60services:
MyPlugin\Services\EnhancedLogger:
decorates: Psr\Log\LoggerInterface
arguments:
$inner: '@.inner'The container is automatically compiled and cached in container/ directory:
my-plugin/
├── container/
│ ├── container.php # Compiled container
│ └── container.meta # Cache validation data
Cache is automatically invalidated when:
- Any source file is modified
- Configuration files change
- Services are added/removed
For production, pre-compile the container:
# Add to composer.json
{
"scripts": {
"compile-container": "WPApp\\Container\\Compiler::compile"
}
}
# Then run
composer compile-container- Use Constructor Injection - Inject dependencies via constructor
- Type Everything - Leverage PHP 8.4's type system
- Keep Services Small - Single responsibility principle
- Avoid Static State - Use dependency injection instead
- Use Interfaces - Program to interfaces, not implementations
- Cache in Production - Pre-compile containers for production
Delete the cache directory:
rm -rf container/Check that:
- Class is in configured resource path
- Class is instantiable (not abstract/interface)
- Namespace matches directory structure
- Composer autoloader is up to date (
composer dump-autoload)
Ensure:
- Method is public
- Hook name is correct
- WordPress hook exists
- Plugin/theme is booted before hook fires
GPL-3.0-or-later. See LICENSE file.