Skip to content

Instantly share code, notes, and snippets.

@JFossey
Last active October 12, 2021 15:43
Show Gist options
  • Save JFossey/4c1acc7512b0a585b42861e22a5bd58e to your computer and use it in GitHub Desktop.
Save JFossey/4c1acc7512b0a585b42861e22a5bd58e to your computer and use it in GitHub Desktop.
Application / DOMAIN Packages: How to separate code (PHP Composer + Laravel)

Managing Packages/Features/DOMAIN Outside of App (PHP Composer + Laravel)

When using an MVC framework like Laravel your /app folder can grow very quickly and start to get messy and complex, and if you want to share code between projects you can very easily by accident duplicate code where common functionality is required.

To overcome this a different structure is required were not everything goes into App, but rather another directory is created were sub-projects/modules or private domain-specific packages are kept.

A recommend structure would be to create a hierarchy of dependencies, were code in App is optionally shared between package but packages try and not depend on each other unless in the same root package namespace.

Benefits

  1. The structure forces developers to think about the DOMAIN and logically group code based on purpose.
  2. It's easy for a new developer to find their way because the code is logically sectioned off by name and purpose.
  3. You don't have multiple un-related files in the same folder EG: app/Http/Controllers/UserRegistration.php & app/Http/Controllers/StockLists.php
  4. If ever an application needs to be broken up and embrace a microservice architecture, the process is simplified because everything is already logically grouped and mostly self-contained.
  5. You easily find everything important related to a package by looking at its service provider.

Namespaces

The location folder name suggests its purpose. Some prefer the name packages some promote the name features. Some argue that they are two different things and you could have a packages or features folder.

For simplicity, we will be using the name package or packages.

/app

In this structure code in /app provides for any common code that is shared in the application, but we try and avoid putting a feature or domain-specific code in the App.

A common situation is where the App namespace provides for abstract base or super classes that other packages will extend from.

/packages/<namespace>/

Location of custom package namespaces. Each directory needs to be added to composer.json for auto-loading to work.

A package namespace should be DOMAIN or feature specific.

For example, if you have a shopping cart checkout process, could create a /packages/ShoppingCart/ package where everything related to the checkout process is kept.

Then you could have a need for code related to statements and invoicing, so you could create a /packages/Billing/ package where all billing related code is kept.

The package structure can be and is recommended to be standardized.

Example/Recommended structure for a package:

Location Description
/packages/<namespace>/README.md Documentation page describing the namespace and sub-packages
/packages/<namespace>/routes.php Package specific routes
/packages/<namespace>/config.php Package specific config
/packages/<namespace>/ServiceProvider.php Laravel Service provider that registers the routes and config
/packages/<namespace>/Contracts/* Package specific interfaces and contracts
/packages/<namespace>/Controllers/* Package controllers that act on defined routes
/packages/<namespace>/Commands/* Package CLI artisan commands
/packages/<namespace>/Jobs/* Package Jobs

composer.json

To make this work you need edit your composer PSR-4 namespaces.

Options 1: Single namespace entry (recommended)

Every folder in /packages will be its own root level namespace. This has the benefit of not needing to edit composer every time you want a new namespace.

EG: /packages/Sales will become the /Sales namespace. To do this you do not provide a name but just and an empty string "": "packages/".

    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "": "packages/"
        }
    },

Option 2: Composer entry per namespace

Each package namespace is explicitly added to Composer.

The only benefit here is that you can provide different namespace root eg /src.

    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "Examples\\": "packages/examples/src",
            "System\\": "packages/system",
            "Sales\\": "packages/sales",
            "Utils\\": "packages/utilities"
        }
    },

Options 3: Single root namespace

One single branded/named namespace that all packages fall under and each sub-folder is a second level to the root namespace.

This is not very different from App and will require extra discipline to keep it clean as it can easily get messy the same way App can get messy without clear groupings because it is a single root namespace.

    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "AbcWidgets\\": "packages/"
        }
    },

Example Package

Included here is a basic GIST example package providing all the basic elements that could go into a package in a Laravel App.

Please note there are some elements left out as it was deemed not necessary to provide examples as it would be no different from what normal documentation provides or unnecessary noise. Namely Controllers, contract interface and service class.

Additionally please note that as gist does not allow for sub-folders this example is a flat package example. Normally with large packages, you would group elements into folders as shown above.

{
"autoload": {
"psr-4": {
"App\\": "app/",
"": "packages"
}
}
}
<?php
/*
|------------------------------------------------------------------------------
| Example Package Main Config
|------------------------------------------------------------------------------
|
*/
return [
/**
* Something important example config value
*/
'something-important' => true,
/**
* Dedicated Logging
*/
'log' => [
/**
* Dedicated Provisioning Logging Level
*/
'level' => 'debug',
/**
* Number of days of log files to keep before rotating and remove old logs
*/
'max_files' => 30,
/**
* Dedicated Logging location / path.
*/
'log_path' => storage_path('logs/example/something.log'),
],
];
<?php
declare(strict_types=1);
namespace Example;
// Framework
use Illuminate\Support\Facades\Facade;
/**
* Example facade using manager singleton binding from IoC.
*/
class ExampleFacade extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'example.manager';
}
}
<?php
declare(strict_types=1);
namespace Example;
// Package
use Example\Contracts\ServiceInterface; // Contract / Interface
// Framework
use Illuminate\Log\Writer as LogWriter;
// Vendor
use Monolog\Logger as MonologLogger;
/**
* Example manager service used as the root instance of a facade.
*/
class ExampleManager
{
/**
* Something service.
*
* @var ServiceInterface
*/
protected $service;
/**
* Custom dedicated log writer.
*
* @var LogWriter
*/
protected $logWriter;
/**
* Create ExampleManager instance.
*
* @param array $logOptions
* @param ServiceInterface $service
*/
public function __construct(array $logOptions, ServiceInterface $service)
{
$this->makeLogger($logOptions);
$this->service = $service;
}
/**
* Do something important using injected service.
*
* @return boolean
*/
public function doSomething(): bool
{
return $this->service->doSomething();
}
/**
* Get dedicated logger instance.
*
* @return MonologLogger
*/
public function log(): LogWriter
{
return $this->logWriter;
}
/**
* Custom Dedicated Porting Log Writer for SOAP logs.
*
* @return void
*/
protected function makelogger(array $logOptions): void
{
if ($this->logWriter instanceof LogWriter) {
return $this->logWriter;
}
$this->logWriter = new LogWriter(new MonologLogger('example'), App::make('events'));
$path = Config::get('example.log.path');
$maxFiles = Config::get('example.log.max_files', 30);
$logLevel = Str::lower(Config::get('example.log.level', 'debug'));
$this->logWriter->useDailyFiles($path, $maxFiles, $logLevel);
}
}
<?php
declare(strict_types=1);
namespace Example;
// Package
use Example\ExampleManager; // Facade singleton instance / root
use Example\Commands\ExampleTool; // Artisan Command
use Example\Services\CustomService; // Dependency
use Example\Contracts\ServiceInterface; // Contract / Interface
// Framework
use Illuminate\Support\Facades\Route;
use Illuminate\Foundation\Support\Providers\RouteServiceProvider as ServiceProvider;
// Aliases
use Config;
/**
* An example custom package / feature service provider.
*
* To use me add Example/ExampleServiceProvider::class to your config/app.php.
*/
class ExampleServiceProvider extends ServiceProvider
{
/**
* Label our custom views namespace
*/
public const VIEWS_NAMESPACE = 'Example';
/**
* The namespace is applied to your controller routes.
*
* In addition, it is set as the URL generator's root namespace for building
* named routes.
*
* @var string
*/
protected $namespace = 'Example\Controllers';
/**
* List of Commands defined by this package.
*
* @var array
*/
protected $commands = [
ExampleTool::class,
];
/**
* Register any application services.
*
* @return void
*/
public function register(): void
{
// Register our default config
$this->mergeConfigFrom(__DIR__ . '/config.php', 'example');
// Application container binding
$this->app->bind(ServiceInterface::class, CustomService::class);
// Example Service Class binding for use in facade
$this->app->singleton('example.manager', $this->registerManager());
// Register our commands with Artisan
$this->commands($this->commands);
}
/**
* Bootstrap any application services.
*
* @return void
*/
public function boot(): void
{
parent::boot();
// Set Views path Hint for Custom Package
$this->loadViewsFrom(__DIR__ . '/Views', self::VIEWS_NAMESPACE);
}
/**
* Define the routes for the application.
*
* @return void
*/
public function map(): void
{
Route::prefix('example')
->middleware('api')
->namespace($this->namespace)
->group(__DIR__ . '/routes.php');
}
/**
* Application Container Binding Closure for registering the Example Manager.
*
* @return Closure
*/
protected function registerManager()
{
return function ($app) {
$logOptions = Config::get('example.log');
$something = Config::get('example.something-important');
$service = $app->make(ServiceInterface::class);
ExampleManager::setStaticSomething($something);
return $app->makeWith(ExampleManager::class, compact('logOptions', 'service'));
};
}
}
<?php
/*
|--------------------------------------------------------------------------
| Example API Routes
|--------------------------------------------------------------------------
|
| Assumed: (see ExampleServiceProvider.php)
| - prefix = example
| - domain = *
| - namespace = Example\Controllers
| - middleware = api
|
*/
/*
|------------------------------------------------------------------------------
| Publicly Accessible
|------------------------------------------------------------------------------
|
*/
Route::get('something/show', 'SomethingController@show')->name('example.something.show');
/*
|------------------------------------------------------------------------------
| API Auth v1
|------------------------------------------------------------------------------
|
*/
Route::middleware(['auth:api'])->prefix('v1')->group(function () {
/**
* example/v1/clients/*
*/
Route::prefix('clients')->group(function () {
Route::get('{client}/status', 'ClientController@status');
Route::get('{client}/logs', 'ClientController@logs');
});
/**
* example/v1/widget/*
*/
Route::resource('widget', 'WidgetController', [
'only' => [ 'index', 'store', 'show', 'update', 'destroy' ],
]);
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment