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?".
Comments