Wrapping A Modern PHP Architecture Around A Legacy WordPress Site
Case study of the Daniels Trading
site
(and its related siblings)
The client
Goals of Web Efforts
Use WordPress for content management
Existing mix of ColdFusion, old WordPress, etc...
Refactor existing site functionality
Former development efforts were of varying quality
Add functionality to satisfy new requirements
Provide better tools to adapt to the market
Improve compliance
Growing number of regulations that need to be addressed
Sites differ in technology, structure & scope
Individual sites are complex
Lots of custom content...
lots of custom
code!
Challenges
Vast scope
Large number of sites with lots of custom behavior
Moving target
Mergers & acquisitions keep number of sites growing
Difficult maintenance
Websites in differing states scattered across differing infrastructures
Current
development work not scalable enough to keep up with scope!
Primary Goals of Architecture Proposal
Best practice: There needs to be a
standardized way of accessing different subsystems, so
that they can communicate with each other.
» Business Goal: Have code be independent
of global state or file system layout.
Best practice: There needs to be a
standardized way of instantiating specific
implementations of a subsystem referenced by unspecific interfaces.
» Business Goal: Isolating needed code
changes to a single subsystem at a time.
Best practice: There needs to be an
application-wide logging mechanism. The application should
not need to know about the specific implementation of the logging system. The log data needs to be
freely routable to different storage & processing
systems without changes to the application itself.
» Business Goal: Being able to retrace
errors.
» Business Goal: Monitoring for
exceptional scenarios.
Best practice: There needs to be a persistent
messaging queue that decouples event
initiators from event handlers.
» Business Goal: Maintaining transaction
integrity.
» Business Goal: Allowing events to cross
session boundaries.
Secondary Goals of Architecture Proposal
Challenge: The number of sites as well as
their respective state and structure is constantly changing.
» Business Goal: Deploying architecture
incrementally.
» Business Goal: Running both new code
and legacy code side-by-side.
Challenge: Some subsystems are not integrated
into WordPress.
» Business Goal: Running architecture
code outside of WordPress framework.
Challenge: Some subsystems can not directly be
changed.
» Business Goal: Injecting and decorating
without modifying source.
composer.json
(extract)
{
"name": "danielstrading/danielstrading.com",
"require": {
"php": ">=7.0",
"gaa/gaa-custom-login": "^1",
"gaa/gaa-featured-products": "^1",
"gaa/gaa-gravity-forms": "^1",
"gaa/gaa-log": "^1",
"gaa/gaa-member-area": "^1",
"gaa/gaa-membership": "^1",
"gaa/gaa-services": "^1",
"gaa/gaa-sitemap": "^1",
"gaa/gaa-tracking": "^1",
"gaa/gaa-virtual-services": "^1",
"gaa/gaa-webinars": "^1"
[ ... ]
}
}
.env
(extract)
#------------------------------------- SITE SETTING -------------------------------------#
# Code identifying the current site. This controls what site-specific configuration to
# load through the Config Mapper.
GAIN_SITE=DT
#--------------------------------- ENVIRONMENT SETTING ----------------------------------#
# Current environment that the site runs in. This controls what environment-specific
# configuration to load through the Config Mapper.
WORDPRESS_ENV=development
#----------------------------- MANDATORY DATABASE SETTINGS ------------------------------#
# Database connection settings for the WordPress database.
DB_NAME=<database name>
DB_USER=<database user>
DB_PASSWORD=<database password>
[...]
Main Components
Auto-wiring Dependency Injector
Central control over instantiations.
Service Locator
Inter-plugin dependency resolution.
Config Mapper
Site- and environment-specific customizations.
Log Manager
Central control over logging and notifications.
Message Bus (work in progress)
Decouple business processes from web server.
"Auto-wiring Dependency Injector"
"Auto-wiring
Dependency Injector"
An object instance or scalar value that the current code needs to be
able to work.
"Auto-wiring Dependency
Injector"
The dependency is not fetched from the current code itself, but injected
from the outside.
"Auto-wiring Dependency Injector"
The injection is done recursively, resolving the dependencies's
dependencies, and so on...
Auto-wiring Dependency Injector
Sole source of instantiations.
For all types that are not considered to be "newable",
the Injector takes care of the instantiation.
Allows coding against interfaces.
Consuming code never needs to know about the specific implementations to use. It only requires
any object fulfilling the interface it needs and then just uses that without needing additional
checks.
Allows overriding default
implementations.
Through the use of the Config Mapper, implementations can be overridden globally, per plugin, per
site or per environment.
Injection Example
1/2
interface CustomerDatabase { public function get_customer_name( int $id ) : string; }
final class WordPressCustomerDatabase implements CustomerDatabase {
private $wpdb;
public function __construct( wpdb $wpdb ) { $this->wpdb = $wpdb; }
public function get_customer_name( int $id ) : string {
return $this->wpdb->get_var( $this->wpdb->prepare(
"SELECT name FROM {$this->wpdb->customers} WHERE id = %d", $id
) );
}
}
$injector->alias( CustomerDatabase::class, WordPressCustomerDatabase::class );
$injector->delegate( wpdb::class, function () { return $GLOBALS['wpdb']; } );
$customers = $injector->make( CustomerDatabase::class );
echo $customers->get_customer_name( $id );
Injection Example
2/2
class CustomerDashboard {
private $database;
public function __construct( CostumerDatabase $database ) {
$this->database = $database;
}
}
$dashboard = $injector->make( CustomerDashboard::class );
Service Locator
Each site has multiple Service Providers.
A Service Provider is often conceptually equivalent to a plugin. A plugin can however contain
more than one Service Provider.
-
Each Service Provider has one or more Services.
A Service is a one coherent grouping of functionality.
-
Each Service Provider has any number of Dependencies.
Each Dependency is another Service or Virtual Service.
-
A Service Provider only registers its Services once all of its
Dependencies are met.
As this is checked each time a new Service Provider is loaded, this takes care of both dependency
management and loading order.
Service Locator Example
interface CustomerDashboard { public function render( array $context = [] ) : string; }
final class CustomerDashboardServiceProvider extends AbstractServiceProvider {
public function get_name() { return 'Customer Dashboard'; }
public function get_dependencies() { return [ 'IsLoggedIn', 'CustomerDatabase' ]; }
public function get_services() { return [
'CustomerDashboard' => CustomerDashboard::class,
]; }
}
<h2>Customer Dashboard</h2>
<div class="customer-dashboard">
<?= Services::get( 'CustomerDashboard' )->render( [ 'user_id' => $id ] ) ?>
</div>
Dependency resolution
1/4
Debugging plugin providing an overview
Dependency resolution
2/4
Service providers are not immediately activated when registered.
Dependency resolution
3/4
Service providers are not immediately activated when registered.
They are enqueued first, and only get activated once all of their dependencies are met.
Dependencies are Services provided by other Service Providers.
Dependency resolution
4/4
Service providers are not immediately activated when registered.
They are enqueued first, and only get activated once all of their dependencies are met.
Dependencies are Services provided by other Service Providers.
Services where the dependencies are not met stay enqueued, and thus inactive.
Config Mapper
Reusable code and business data are decoupled through Config
Files.
A Config File contains all the data that is not 100% reusable across all projects. It also adds
information to the Injector to map interfaces to implementations.
-
The Config Mapper assembles a final Config File.
When the Dependency Injector asks for a specific Config File to feed as a dependency into a class
to instantiate, the Config Mapper assembles the correct for the current context.
-
Same code, different configuration per site.
All the general-purpose code is shared as is. Fix the bug for one site, and you fix it for all
the sites.
-
Configure your code per environment.
No need for conditional code, just use the implementations that fit the environment.
Config Mapper Example
1/2
GAIN_SITE=DT
WORDPRESS_ENV=development
$config = Config::get( 'plugin-name', PLUGIN_DIR, __NAMESPACE__ );
This will cause the
Config Mapper to load the following files, in the listed order, and override existing settings from
earlier files with the new values from the later files:
${PLUGIN_DIR}\config\defaults.php
${PLUGIN_DIR}\config\dt.php
${PLUGIN_DIR}\config\dt-development.php
- Path found under the
'plugin-name'
key
in the Custom Config Mappings file.
When all of these have been processed and merged (files that do not exist are
simply skipped), the SubConfig under __NAMESPACE__
will be
extracted and passed into the $config
variable.
Config Mapper Example
2/2
class Notifier { public function __construct( EmailSender $sender ); }
GAIN_SITE=DT
WORDPRESS_ENV=production
GAIN_SITE=DT
WORDPRESS_ENV=development
// File: config/dt-production.php
return [ Injector::STANDARD_ALIASES => [
'EmailSender' => SendGridEmailSender::class,
] ];
// File: config/dt-development.php
return [ Injector::STANDARD_ALIASES => [
'EmailSender' => NullEmailSender::class,
] ];
Services::get( 'Notifier' )->send( $message );
[2017-03-18 12:15] [DT|production] Notifier.INFO:
Sent email through SendGrid.
[2017-03-18 12:15] [DT|development] Notifier.INFO:
Skipped sending email.
Log Manager
Entire code base is only coupled to the PSR-3
interface.
None of the consuming needs to be concerned about how the logger is implemented or where to get
it from.
-
Safe default to allow the logger to be used everywhere.
By default, the Psr\Log\LoggerInterface
is mapped to a NullLogger
.
-
Centralized control from outside of consuming code.
A separate plugin can provide the configuration for all logging to be routed as needed.
-
Routing is based on the calling code's namespace.
Any namespace level can be individually targeted and re-routed or pre/post-processed as
needed.
Log Manager Example
namespace Deeply\Nested\Namespace;
class ClassA { public function do_work() {
$this->logger->debug( 'Debug Class A' );
$this->logger->warning( 'Warning Class A' );
} }
class ClassB { public function do_work() {
$this->logger->debug( 'Debug Class B' );
$this->logger->warning( 'Warning Class B' );
} }
'Deeply\Nested' => [ Handler::LEVEL => LogLevel::INFO ],
'Deeply\Nested\Namespace\ClassB' => [ Handler::LEVEL => LogLevel::DEBUG ],
( new Deeply\Nested\Namespace\ClassA )->do_work();
( new Deeply\Nested\Namespace\ClassB )->do_work();
[2017-03-18 12:18] [DT|development] Deeply\Nested.WARNING: Warning Class A.
[2017-03-18 12:19] [DT|development] Deeply\Nested\Namespace\ClassB.DEBUG: Debug Class B.
[2017-03-18 12:19] [DT|development] Deeply\Nested\Namespace\ClassB.WARNING: Warning Class B.
Message Bus
(WIP)
Decouple business processes from the current web request.
When a visitor makes a request, it is passed on to the message queue as a Command or Event (and
not processed by the WordPress process). Errors in one don't impact the other.
Simplifies transactional integrity.
Requests persisted to the message queue cannot get lost.
If the worker cannot successfully complete a process, it can retry until a certain threshold has
been reached, and then notify about the failure.
Failed processes can be re-run once the error condition has been eliminated.
Serves as a transparent bridge between processes and servers.
The web server on which a request is made does not need to be the same server that will do the
actual processing. It only needs to know that the Message Bus has accepted the request and trust
it will be handled.
This allows for better scalability, by offloading worker processes to different servers and
letting the Message Bus act as load balancer.
Message Bus Example
Web Server
Business Process Server
class SubscribeToNewsletter implements Command {
public function __construct( Email $email ) {
$this->email = $email; } }
Bus::registerCommandHandler(
SubscribeToNewsLetter::class,
NewsletterSubscriptionHandler::class );
add_action( 'submit_newsletter_form',
function ( $email ) {
// Trust the Bus to handle actual work.
CommandBus::handle(
new SubscribeToNewsletter( $email )
); } );
class NewsletterSubscriptionHandler {
public function handle( Command $command ) {
// Persist to database.
EventBus::notify(
new NewSubscriber( $command->email )
); } }
// Just confirm reception and exit process.
echo View::render(
'thank-you',
[ 'email' => $email ]
);
$handlers = [ AddToCRM::class,
RegisterDemoAccount::class,
SendWelcomeEmail::class ];
foreach ( $handlers as $handler ) {
Bus::registerEventHandler(
NewSubscriber::class, $handler );
}
Although the sites are still very different...
...they share the same fundamental codebase.
Benefits of the new architecture
-
Fast site on-boarding
Simple sites can be moved to the architecture and gain all of its benefits within weeks
-
Robust code
Strong type-hinting eliminates a lot of bug sources
-
Flexible code
Coding against interfaces makes changes easy and avoids side-effects
-
Reusable code
Entire codebase reusable across all sites, reducing maintenance effort
-
Fast error detection
Extensive logging, notifications and separations of concern make debugging less painful
Did we meet our Business Goals?
1/10
-
Business Goal: Have code be independent of global state or file system
layout.
Most packages and plugins have as sole
requirement the presence of the gaa/gaa-services
package.
This package can be provided either as a
must-use plugin or as a Composer dependency.
All dependencies are injected through
constructors or setters. No global state is used.
This works from within the WordPress framework
just as well as it does from the command line.
Did we meet our Business Goals?
2/10
-
Business Goal: Isolating needed code changes to a single subsystem at a
time
As all components are decoupled and are coded
against interfaces, implementation details of a given dependency can change at any time without
needing changes to the consuming code.
Implementations can even be completely
replaced at any time without changes to the consuming code, by central configuration of the
Injector.
Did we meet our Business Goals?
3/10
-
Business Goal: Being able to retrace errors
Log files give a detailed account of what has
happened before and after an error condition occurred.
Log files can be browsed either
chronologically across all subsystems, or filtered at any granularity.
PSR-4 namespaces and class naming give
precise trace information.
Notifications are sent out to enable the
debugging developer to act as soon as possible, while the error condition might still be
present.
Did we meet our Business Goals?
4/10
-
Business Goal: Monitoring for exceptional scenarios
Logging is routed into a Graylog server, where
filters and notification triggers can be set up across the entire collection of sites.
(WIP)
Critical errors are immediately posted to a
Slack team to allow for immediate action and resolution.
Did we meet our Business Goals?
5/10
-
Business Goal: Maintaining transaction integrity
Requests generated by user actions and
business process triggers are persisted as business domain Commands & Events. Once
persisted, these contain all necessary data to act upon the requests, even outside of the
process/context that they were generated in.
(WIP)
The Message Bus manages the Commands &
Events, dispatches them to the concerned services (even across servers) and keeps track of their
completion.
(WIP)
Did we meet our Business Goals?
6/10
-
Business Goal: Allowing events to cross session boundaries
Once a request has been persisted by the
Message Bus, the current process can die without causing any data loss or side-effects.
(WIP)
Services and worker processes can be
transparently distributed across several servers without any changes to the consuming code.
(WIP)
Did we meet our Business Goals?
7/10
-
Business Goal: Deploying architecture incrementally
The entire architecture can be installed on an
existing WordPress site without any direct side-effects.
Installation can be done either through a
Composer stack, or as standard WordPress plugins.
The central configurations can be adapted for
each site and/or environment as needed.
Did we meet our Business Goals?
8/10
-
Business Goal: Running both new code and legacy code side-by-side
Consumption of the architecture's services
does not require inheriting base objects or using some sort of module structure.
Static Facades/Proxies are provided for
localized access where dependency injection trees cannot be used.
Arbitrary WordPress Core or third-party plugin
functionality can be represented as virtual services to take part in conditional loading and
dependency resolution mechanisms.
Did we meet our Business Goals?
9/10
-
Business Goal: Running architecture code outside of WordPress framework
The main gaa/gaa-services
package
does not depend on WordPress and can be loaded as a standard PHP component.
The gaa/gaa-log
package only
depends on gaa/gaa-services
and can be used to log command-line tools, test suites,
etc.
Domain logic packages that don't directly
depend on WordPress can provide command-line tools and be used for bulk management and
automation.
Did we meet our Business Goals?
10/10
-
Business Goal: Injecting and decorating without modifying source
The Aspect-oriented Framework included with
gaa/gaa-services
can attach custom behavior to third-party code without changes to
its source.
Cross-cutting concerns can be dealt with
without obscuring the actual logic.
Use Config Files!
The config files turn random business-specific code into data-driven business logic that is reusable
across multiple sites.
In this way, we can turn all of our custom functionality into resuable Composer packages that can be
required by each site on an as-needed basis.
Use Dependency Injection!
The auto-wiring dependency injector lets us defer decisions about implementation details to later code.
Our business logic is clean, elegant and easy to understand and debug.
Coupled with the config files, this also allows us to provide environment-specific setups without
muddling everything up with conditional code.
Use WordPress for its Strengths, Delegate the Rest!
We went through great lengths to not negatively impact the content editing experience, and even enhance
it to improve the productivity of the marketing team.
However, we moved all non-CMS stuff that WordPress was doing into different subsystems optimized for that
purpose.
Try it yourself!
I planned on having a scaffolding tool ready for WordCamp London for you to roll your own site
backbone.
However, time constraints didn't quite allow for this.
For now, head over to the following URL to get an overview of the public packages, and to get updates
about when the scaffolding tool will be ready:
https://www.alainschlesser.com/bright-nucleus-architecture/
Big thanks go to both GAIN Capital/Global Asset Advisors and Gamajo Tech for making it possible to release most of the architectural code into open source!