Chrome for Android fix.

Wrapping A Modern PHP Architecture Around A Legacy WordPress Site

Case study of the Daniels Trading site
(and its related siblings)

Alain Schlesser
Software Engineer & WordPress Consultant

The client *

* as subcontractor to UK-based Gamajo Tech, Gary Jones

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!

Our Approach

Primary Goals of Architecture Proposal

  1. 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.

  2. 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.

  3. 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.

  4. 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

  1. 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.

  2. Challenge: Some subsystems are not integrated into WordPress.

    » Business Goal: Running architecture code outside of WordPress framework.

  3. Challenge: Some subsystems can not directly be changed.

    » Business Goal: Injecting and decorating without modifying source.

Site stack

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.

* Examples of newable types: value objects, exceptions, DateTime*, etc.

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:

  1. ${PLUGIN_DIR}\config\defaults.php
  2. ${PLUGIN_DIR}\config\dt.php
  3. ${PLUGIN_DIR}\config\dt-development.php
  4. 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 );
                        }
                    

Our Results

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.

Take-aways

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!

The End

I'm Alain Schlesser.


Follow me on Twitter:

  @schlessera

Or visit my Personal Blog:

   www.alainschlesser.com