The era of full-stack frameworks is behind us. Nowadays, framework vendors are splitting their monolithic repositories into components with the help of Git subtree, allowing you to cherry-pick ones that you need for your project. This means that you can build your application on top of Zend Service Manager, Aura Router, Doctrine ORM, Laravel (Illuminate) Eloquent, Plates, Monolog, Symfony Cache, or any component out there that can be installed via Composer.

Robust project structure

Basic step is to establish and maintain a solid, framework-agnostic project structure in order to be able to combine and assemble diverse framework components in an efficient manner. I've dedicated a full article to this subject covering matters of directory structure, organizing and grouping sources, naming conventions, and similar.

Choose the right tool for the job

Throughout the development of a project focus should always be on its core business logic. For all the things that are not the domain matters of your application you should turn to open source goodies, components and libraries that facilitate some common, cross-cutting concerns of the application development. DBAL, ORM, routing, mailer, cache, logger are only some of the examples of something that you should not be reinventing.

Let me remind you that you can use components independently of almost every framework, for example Zend Framework, Symfony, Laravel, Aura, etc., thus making composer.json dependencies look very diverse:

{
   "require": {
     "php": "^7.0",
     "container-interop/container-interop": "^1.0",
     "zendframework/zend-servicemanager": "^3.0.3",
     "symfony/console": "^3.1",
     "symfony/event-dispatcher": "^2.8",
     "doctrine/dbal": "^2.5",
     "zendframework/zend-filter": "^2.7",
     "aura/intl": "^3.0",
     "psr/log": "^1.0",
     "monolog/monolog": "^1.21",
     "illuminate/support": "^5.3",
     "league/plates": "^3.1",
     "slim/slim": "^3.7",
     "mongodb/mongodb": "^1.0",
     "filp/whoops": "^2.1",
     "ramsey/uuid": "^3.5",
     "robmorgan/phinx": "^0.6.5",
     "psr/simple-cache": "^1.0",
     "symfony/cache": "3.3.*@dev"
   }
}

Decouple from the framework

While being able to use different framework components is a gracious privilege, it can put you in a hopeless situation if used recklessly. Crucial, but not an easy task is to decouple your business logic from the framework or library that you're using. Otherwise, you might have difficulties not only when trying to switch to a component from some different vendor, but also when upgrading to a newer version of the very same component.

Being 100% decoupled from the framework code is impossible unless you're not using it at all, but what you can do is to significantly reduce coupling. Establish an interface layer that abstracts and decouples your code from external dependencies or use PSR interfaces for things that are standardized so far in order to minimize the efforts needed for switching to some alternative implementation of a component. In general, programming to an interface is the principle you should practice. Briefly, this means that in your code you should use interfaces and not concrete implementations for type hinting.

Ideally, these are the only places in the system where you should allow direct coupling to happen:

  • concrete implementations of services that abstract external dependencies
  • factories
  • dispatch targets, for example middleware, controllers, action handlers, CLI commands, assuming that they are "thin", containing no business logic

Configuration management

Instead of hard-coding database connection parameters in your code, you put those and similar kind of parameters in configuration files, so they can be easily manipulated and changed in different environments (development, production, etc.).

There are several strategies for managing configuration files. The most obvious is having one configuration file per environment which is then dynamically loaded based on the environment variable that specifies environment in which application is running:

config/
    config_development.php
    config_production.php
    config_testing.php

The main drawback of this simplistic approach is the necessity for maintaining duplicated parameters across multiple configuration files.

What I prefer is a rather different principle for handling environment-specific configuration, advocated by Zend Framework, nicely described in framework's documentation. It allows for organizing configuration files this way:

config/
    database.global.php
    development.php
    global.php
    logger.global.php
    production.php
    services.global.php

In this case, parameters can be nicely distributed in separate configuration files based on their purpose and environment-specific overrides happen in discrete, unversioned configuration files which contain only parameters that are different. Files are merged into single configuration in the order specified through a glob brace definition.

Dependency Injection is the key

Practicing Dependency Injection technique is essential for making your code flexible and robust. In this regard, the DI Container is the key surrounding concept that manages logic for constructing and wiring up building blocks of the application.

Stuff that should be defined in the DI Container:

  • general-purpose services (database adapter, cache, mailer, logger, etc.)
  • domain services, repositories
  • middleware, controllers, action handlers (yes, these have dependencies that need to be injected!)
  • web and CLI application runners

There's a common name for all these objects - services. A service is a generic name for any PHP object that is designed for a specific purpose (e.g. sending emails) and is used throughout the application whenever functionality it provides is needed. If a service has a complex construction logic (has dependencies) or it is a dependency of some other class, and it's not meant to be instantiated several times within the same request, it should be registered with the DI Container.

Other group of classes are those that represent type, such as domain objects, entities, value objects. Think of User, Post, DateTime as concrete examples of those classes. Those are not services, thus they should not be defined in the container.

Configuring DI Container

Instead of populating DI Container instance programmatically, it is advisable to define application dependencies in the configuration:

return [
    'di' => [
        'factories' => [
            Psr\SimpleCache\CacheInterface::class => App\Cache\CacheFactory::class,
            App\Db\DbAdapterInterface::class => App\Db\DbAdapterFactory::class,
            App\User\UserService::class => App\User\UserServiceFactory::class,
            App\User\UserRepository::class => App\User\UserRepositoryFactory::class,
        ],
    ],
];

Some DI Containers, like Zend Service Manager for example, support this approach out of the box, otherwise you'll have to write some simple logic for populating it based on configuration array.

You may have noticed that I prefer to use fully-qualified name of the interface as a service name for services that provide interface implementations. In case there's no interface I use fully-qualified class name. Reason is simple: code that involves retrieval of services from the container becomes apparent, but also it is easier for a consumer to reason what he is dealing with.

Bootstrapping

Code that loads configuration and initializes DI Container is typically contained in what is known as a bootstrap script. Depending on the configuration strategy and DI Container implementation, it can have the following form:

$config = [];

$files = glob(sprintf('config/{{,*.}global,{,*.}%s}.php', getenv('APP_ENV') ?: 'local'), GLOB_BRACE);

foreach ($files as $file) {
    $config = array_merge($config, include $file);
}

$config = new ArrayObject($config, ArrayObject::ARRAY_AS_PROPS);

$diContainer = new Zend\ServiceManager\ServiceManager($config['services']);
$diContainer->set('Config', $config);

return $diContainer;

DI Container is the end result of bootstrapping operation, through which all further actions take place.

Although this is very simple example, logic for loading and merging configuration can be quite complex. In case of modular systems, configuration is collected from different sources, so some more advanced mechanism for that purpose would be used within bootstrapping.

Phoundation

Bootstrapping logic can become cumbersome and duplicated between projects, so I've created a library called Phoundation, which allows me to have less verbose bootstrap file:

$bootstrap = new Phoundation\Bootstrap\Bootstrap(
    new Phoundation\Config\Loader\FileConfigLoader(glob(
        sprintf('config/{{,*.\}global,{,*.}%s}.php', getenv('APP_ENV') ?: 'local'), 
        GLOB_BRACE
    )),
    new Phoundation\Di\Container\Factory\ZendServiceManagerFactory()
);
$diContainer = $bootstrap();

return $diContainer;

Full example

To have an overall picture of the matter, take this simple blog engine application as an example, which can be used both through web browser (public/index.php) and a command-line (bin/app). It uses Slim micro-framework for the web part of the application and Symfony Console for the CLI part.

Project structure

bin/
    app
config/
    database.global.php
    development.php
    global.php
    production.php
    services.global.php
public/
    index.php
src/
    Framework/                                # general-purpose code, interfaces, adapters for framework components
        Cache/
            CacheFactory.php
        Logger/
            Handler/
                IndexesCapableMongoDBHandler.php
        Queue/
            PheanstalkQueueClient.php
            QueueClientInterface.php
            QueueClientFactory.php
        Web/
            ActionFactory.php
        ConsoleAppFactory.php
        WebAppFactory.php
    Post/                                     # domain code
        Web/
            SubmitPostAction.php
            ViewPostAction.php
        Post.php
        PostRepository.php
        PostRepositoryFactory.php
        PostService.php
        PostServiceFactory.php
    User/                                     # domain code
        CLI/
            CreateUserCommand.php
        Web/
            ViewUserAction.php
        User.php
        UserRepository.php
        UserRepositoryFactory.php
        UserService.php
        UserServiceFactory.php
    bootstrap.php

config/services.global.php

return [
    'di' => [
        'factories' => [
            //Domain services
            Blog\User\UserService::class => Blog\User\UserServiceFactory::class,
            Blog\User\UserRepository::class => Blog\User\UserRepositoryFactory::class,
            Blog\Post\PostService::class => Blog\Post\PostServiceFactory::class,
            Blog\Post\PostRepository::class => Blog\Post\PostRepositoryFactory::class,

            Blog\User\Web\ViewUserAction::class => Blog\Framework\Web\ActionFactory::class,
            Blog\Post\Web\SubmitPostAction::class => Blog\Framework\Web\ActionFactory::class,
            Blog\Post\Web\ViewPostAction::class => Blog\Framework\Web\ActionFactory::class,

            //App-wide (system) services
            Blog\Framework\Queue\QueueClientInterface::class => Blog\Framework\Queue\QueueClientFactory::class,
            Psr\SimpleCache\CacheInterface::class => Blog\Framework\Cache\CacheFactory::class,

            //App runners
            'App\Web' => Blog\Framework\WebAppFactory::class,
            'App\Console' => Blog\Framework\ConsoleAppFactory::class,
        ],
    ],
];

bin/app

#!/usr/bin/env php
<?php

/* @var \Interop\Container\ContainerInterface $container */
$container = require __DIR__ . '/../src/bootstrap.php';

/* @var $app \Symfony\Component\Console\Application */
$app = $container->get('App\Console');

$app->run();

public/index.php

use Slim\Http\Request;
use Slim\Http\Response;

/* @var \Interop\Container\ContainerInterface $container */
$container = require __DIR__ . '/../src/bootstrap.php';

/* @var $app \Slim\App */
$app = $container->get('App\Web');

$app->get('/', function (Request $request, Response $response) {
    return $this->get('view')->render($response, 'app::home');
})->setName('home');
$app->get('/users/{id}', Blog\User\Web\ViewUserAction::class);
$app->get('/posts/{id}', Blog\Post\Web\ViewPostAction::class);
$app->post('/posts', Blog\Post\Web\SubmitPostAction::class);

$app->run();

Final thoughts

Described concept is a skeleton, a shell around a core codebase consisting of the domain logic supported by different general-purpose components. This shell provides a solid basis for building applications using libraries and tools of your choice.

When starting a new project, you should not be asking "what framework should I use?", but "which components should I use?".