Alright so I'm converting a small laravel project to symfony (will get bigger, and the bundling architecture symfony uses will be ideal)
I'm apparently spoiled with laravels facades and eloquent working with existing databases almost right out of the box.
I can't find the most appropriate way to have a wrapper or "helper" class get access to an entities repository.
first let me give a few examples then I will explain what I have attempted. (I'm willing to bounty some points for a good answer but unfortunately the time constraints on the project can't exactly wait)
So in laravel I had all my model classes. Then I created some wrapper / helper classes that would essentially turn the data into something a little more usable (i.e. multiple queries and objects containing more versatile information to work with). And with the magic of facades I could call upon each model and query them without and dependencies injected into these "Helper" classes. keeping them very lean. In symfony it appears the ideal solution is to put all of your reusable database logic in repositories, ok.
In symfony I'm surrounded by Inversion of Control (IoC); which is fine but design pattern is failing to be intuitive for me to fully figure this scenario out. I have tried to create services out every single repository, which works great if being called from a controller or other Dependency Injected (DI) service. But in a standard php class, it appears my hands are tied without passing entity manager to each helper class's constructor. *shivers*
The first limitation is I have zero ability to change the schema of the existing tables (which obviously doesn't change the problem, just don't want anyone to suggest altering the entities).
So how does one accomplish this.
EDIT:
so thanks to #mojo's comment I've pulled off what I wanted to do. Still looking for a better alternative if it exists. (see edit 2 below)
currently I have:
config.yml docterine.orm.entity_managers:
entity_managers:
default:
auto_mapping: true
connection: default
asterisk:
connection: asterisk
mappings:
AsteriskDbBundle: ~
asteriskcdr:
connection: asteriskcdr
mappings:
AsteriskCdrDbBundle:
service.yml
services:
app.services.doctrine.entitymanager.provider:
class: AppBundle\Services\EntityManagerProvider
arguments: [#doctrine]
tags:
- {name: kernel.event_listener, event: kernel.request, method: onKernelRequest}
EntityManagerProvider
namespace AppBundle\Services;
use Doctrine\Bundle\DoctrineBundle\Registry as DoctrineRegistry;
use Doctrine\ORM\EntityManager;
use Symfony\Component\Config\Definition\Exception\Exception;
class EntityManagerProvider
{
/** #var DoctrineRegistry */
private static $doctrine;
public function __construct(DoctrineRegistry $doctrine)
{
static::$doctrine = $doctrine;
}
/**
* #param $class
* #return EntityManager
*/
public static function getEntityManager($class)
{
if(($em = static::$doctrine->getManagerForClass($class)) instanceof EntityManager == false)
throw new Exception(get_class($em) . ' is not an instance of ' . EntityManager::class);
return $em;
}
// oh man does this feel dirty
public function onKernelRequest($event)
{
return;
}
}
Example Controller
$extension = Extension::createFromDevice(DeviceRepository::findById(92681));
ExtendedEntityRepository
namespace AppBundle\Entity;
use AppBundle\Services\EntityManagerProvider;
use AppBundle\Utils\DateTimeRange;
use Doctrine\DBAL\Query\QueryBuilder;
use Doctrine\ORM\EntityManager;
use Doctrine\ORM\Mapping\ClassMetadata;
use Symfony\Component\Config\Definition\Exception\Exception;
class ExtendedEntityRepository extends \Doctrine\ORM\EntityRepository
{
/** #var ExtendedEntityRepository */
protected static $instance;
public function __construct(EntityManager $entityManager, ClassMetadata $class)
{
parent::__construct($entityManager, $class);
if(static::$instance instanceof static == false)
static::$instance = $this;
}
// some horribly dirty magic to get the entity that belongs to this repo... which requires the repos to have the same name and exist one directory down in a 'Repositories' folder
public static function getInstance()
{
if(static::$instance instanceof static == false) {
preg_match('/^(.*?)Repositories\\\([A-Za-z_]*?)Repository$/', static::class, $match);
$class = $match[1] . $match[2];
$em = EntityManagerProvider::getEntityManager($class);
static::$instance = new static($em, $em->getClassMetadata($class));
}
return static::$instance;
}
public static function findById($id)
{
return static::getInstance()->find($id);
}
public static function getQueryBuilder()
{
return static::getInstance()->getEntityManager()->createQueryBuilder();
}
public static function getPreBuiltQueryBuilder()
{
return static::getQueryBuilder()->select('o')->from(static::getInstance()->getClassName(), 'o');
}
public static function findByColumn($column, $value)
{
//if($this->getClassMetadata()->hasField($column) == false)
// throw new Exception($this->getEntityName() . " does not contain a field named `{$column}`");
return static::getPreBuiltQueryBuilder()->where("{$column} = ?1")->setParameter(1, $value)->getQuery()->execute();
}
public static function filterByDateTimeRange($column, DateTimeRange $dateTimeRange, QueryBuilder $queryBuilder = null)
{
if($queryBuilder == null)
$queryBuilder = static::getPreBuiltQueryBuilder();
if($dateTimeRange != null && $dateTimeRange->start instanceof \DateTime && $dateTimeRange->end instanceof \DateTime) {
return $queryBuilder->andWhere(
$queryBuilder->expr()->between($column, ':dateTimeFrom', ':dateTimeTo')
)->setParameters(['dateTimeFrom' => $dateTimeRange->start, 'dateTimeTo' => $dateTimeRange->end]);
}
return $queryBuilder;
}
}
DeviceRepository
namespace Asterisk\DbBundle\Entity\Repositories;
use AppBundle\Entity\ExtendedEntityRepository;
/**
* DeviceRepository
*
* This class was generated by the Doctrine ORM. Add your own custom
* repository methods below.
*/
class DeviceRepository extends ExtendedEntityRepository
{
//empty as it only needs to extend the ExtendedEntityRepository class
}
Extension
namespace AppBundle\Wrappers;
use Asterisk\DbBundle\Entity\Device;
class Extension
{
public $displayName;
public $number;
public function __construct($number, $displayName = "")
{
$this->number = $number;
$this->displayName = $displayName;
}
public static function createFromDevice(Device $device)
{
return new Extension($device->getUser(), $device->getDescription());
}
}
Agent (This is an example of why having repositories access statically is helpful)
namespace AppBundle\Wrappers;
use AppBundle\Utils\DateTimeRange;
use Asterisk\CdrDbBundle\Entity\Cdr;
use Asterisk\CdrDbBundle\Entity\Repositories\CdrRepository;
use Asterisk\DbBundle\Entity\Device;
use Asterisk\DbBundle\Entity\Repositories\FeatureCodeRepository;
use Asterisk\DbBundle\Entity\Repositories\QueueDetailRepository;
use Asterisk\DbBundle\Enums\QueueDetailKeyword;
class Agent
{
public $name;
public $extension;
/** #var Call[] */
public $calls = [];
/** #var array|Queue[] */
public $queues = [];
/** #var AgentStats */
public $stats;
private $_extension;
public function __construct(Device $extension, DateTimeRange $dateTimeRange = null)
{
$this->_extension = $extension;
$this->extension = Extension::createFromDevice($extension);
$this->name = $this->extension->displayName;
$this->calls = $this->getCalls($dateTimeRange);
$this->stats = new AgentStats($this, $dateTimeRange);
}
public function getCalls(DateTimeRange $dateTimeRange = null)
{
/** #var CdrRepository $cdrRepo */
$cdrRepo = CdrRepository::getPreBuiltQueryBuilder();
$query = $cdrRepo->excludeNoAnswer($cdrRepo->filterByDateTimeRange($dateTimeRange));
$cdrs = $query->andWhere(
$query->expr()->orX(
$query->expr()->eq('src', $this->extension->number),
$query->expr()->eq('dst', $this->extension->number)
)
)->andWhere(
$query->expr()->notLike('dst', '*%')
)
->getQuery()->execute();
foreach($cdrs as $cdr) {
$this->calls[] = new Call($cdr);
}
return $this->calls;
}
public function getBusyRange(DateTimeRange $dateTimeRange = null)
{
$on = FeatureCodeRepository::getDndActivate();
$off = FeatureCodeRepository::getDndDeactivate();
$toggle = FeatureCodeRepository::getDndToggle();
$query = CdrRepository::filterByDateTimeRange($dateTimeRange);
/** #var Cdr[] $dndCdrs */
$dndCdrs = $query->where(
$query->expr()->in('dst', [$on, $off, $toggle])
)
->where(
$query->expr()->eq('src', $this->extension->number)
)->getQuery()->execute();
$totalTimeBusy = 0;
/** #var \DateTime $lastMarkedBusy */
$lastMarkedBusy = null;
foreach($dndCdrs as $cdr) {
switch($cdr->getDst())
{
case $on:
$lastMarkedBusy = $cdr->getDateTime();
break;
case $off:
if($lastMarkedBusy != null)
$totalTimeBusy += $lastMarkedBusy->diff($cdr->getDateTime());
$lastMarkedBusy = null;
break;
case $toggle:
if($lastMarkedBusy == null) {
$lastMarkedBusy = $cdr->getDateTime();
}
else
{
$totalTimeBusy += $lastMarkedBusy->diff($cdr->getDateTime());
$lastMarkedBusy = null;
}
break;
}
}
return $totalTimeBusy;
}
public function getQueues()
{
$query = QueueDetailRepository::getPreBuiltQueryBuilder();
$queues = $query->where(
$query->expr()->eq('keyword', QueueDetailKeyword::Member)
)->where(
$query->expr()->like('data', 'Local/'.$this->extension->number.'%')
)->getQuery()->execute();
foreach($queues as $queue)
$this->queues[] = Queue::createFromQueueConfig(QueueDetailRepository::findByColumn('extension', $queue->id), $queue);
return $this->queues;
}
}
EDIT 2:
Actually I forgot I declared each repository as a service, so I could omit the black magic voodoo in the getInstance() method. But loading the service on kernel event seems like a bad idea...
parameters:
entity.device: Asterisk\DbBundle\Entity\Device
services:
asterisk.repository.device:
class: Asterisk\DbBundle\Entity\Repositories\DeviceRepository
factory: ["#doctrine.orm.asterisk_entity_manager", getRepository]
arguments:
- %entity.device%
tags:
- {name: kernel.event_listener, event: kernel.request, method: onKernelRequest}
Edit 3
Cerad gave me an answer on my other related question That suggested using a single kernel event listener service and injecting each repository as a dependency. Thus allowing me to access the repositories statically. My only concern is the overhead required to load each repository on every request. My ideal method would be lazy load the repositories, but I'm unaware of a method at this time. proxy-manager-bridge looked promising but with my singleton pattern I don't think it will work.
Related
I started to wonder about what exactly is the purpose of service providers in Laravel, and why they work in the way they do. After searching through some articles,
the key points of service providers in my understanding are:
Simplifies object creation (Laravel What is the use of service providers for laravel)
Decoupling your code (r/laravel: When to use service providers?)
Dependency injection
Reduces technical debt
So it basically binds an implementation to an interface, and we can use it by
$app(MyInterface::class)
or something like that, and we can just change the implementation when needed, only in one place, and the rest of our code which depends on it won't break.
But i still can not grasp the concept, why they are the way they are, it seems overcomplicated. I peaked in to the code, it was certainly a ton of work to make Service Providers & Containers work, so there must be a good reason.
So to learn further, i tried to make my own, more simple version of it, which achieves the same goals. (i obviously lack a lot of info on this, and most probably missed some other goals)
My question is, why would this implementation would not satisfy the same use cases?
Service.php
namespace MyVendor;
/**
* Abstract class for creating services
*/
abstract class Service
{
/**
* Holds the instance of the provided service
*
* #var mixed
*/
private static mixed $instance = null;
/**
* Retrieves the instance of the provided service & creates it on-demand
*
* #return mixed
*/
public static function get(): mixed
{
if (self::$instance === null) {
self::$instance = static::instantiate();
}
return self::$instance;
}
/**
* A function which contains the service's object creation logic
*
* #return mixed
*/
abstract protected static function instantiate(): mixed;
}
Example implementation:
For the example, i chose an interface to parse environment variables, as i already had phpdotenv in my project as a dependency
Services/DotenvParser/DotenvParserInterface.php
namespace MyVendor\Services\DotenvParser;
/**
* This is the service interface i want to provide
*/
interface DotenvParserInterface
{
public function parse(string $directory, string $fileName = ".env"): array;
}
Now i will have 2 implementations of this class. I will pretend that a lot of my code already depends on DotenvParserInterface. An old, hacky one which "depends" on another thing, and the replacement for it which uses phpdotenv
A quick fake dependency:
Services/DotenvParser/Dependency.php
namespace MyVendor\Services\DotenvParser;
class Dependency
{
private bool $squeeze;
public string $bar;
public function __construct(string $foo, bool $squeeze)
{
$this->squeeze = $squeeze;
$this->bar = $foo;
if($this->squeeze){
$this->bar .= " JUICE";
}
}
}
Our old code:
Services/DotenvParser/OldDotenvCode.php
namespace MyVendor\Services\DotenvParser;
use BadMethodCallException;
use InvalidArgumentException;
class OldDotenvCode implements DotenvParserInterface
{
/**
* Our fake dependency
*
* #var Dependency
*/
private Dependency $foo;
private string $dir;
private string $fileName;
private string $contents;
private array $result;
public function __construct(Dependency $myDependency)
{
$this->foo = $myDependency;
}
/**
* Implementation of DotenvParserInterface
*
* #param string $directory
* #param string $fileName
* #return array
*/
public function parse(string $directory, string $fileName = ".env"): array
{
try{
$this->setDir($directory)->setFileName($fileName);
}catch(BadMethodCallException $e){
throw new InvalidArgumentException($e->getMessage(), 0, $e);
}
$this->getEnvContents();
$this->contents = $this->getEnvContents();
$this->result = [];
foreach(explode("\n", $this->contents) as $line){
$exploded = explode("=", $line);
$key = $exploded[0];
$value = (isset($exploded[1])) ? trim($exploded[1], "\r") : "";
if($this->foo->bar === "ORANGE JUICE"){
$value = trim($value, "\"");
}
$this->result[$key] = $value;
}
return $this->result;
}
#region Old, bad stuff
public function setDir(string $directory): self{
if(!\is_dir($directory)){
throw new InvalidArgumentException("Directory $directory is not a valid directory");
}
$this->dir = rtrim($directory, "/");
return $this;
}
public function setFileName(string $fileName): self{
if(empty($this->dir)){
throw new BadMethodCallException("Must call method setDir() first with a valid directory path");
}
$fileName = ltrim($fileName, "/");
if(!\file_exists($this->dir . "/" . $fileName)){
throw new InvalidArgumentException("File $fileName does not exist in provided directory {$this->dir}");
}
$this->fileName = $fileName;
return $this;
}
private function getFilePath(): string{
if(empty($this->fileName)){
throw new BadMethodCallException("Must call method setFileName() first");
}
return $this->dir . "/" . $this->fileName;
}
private function getEnvContents(): string{
return \file_get_contents($this->getFilePath());
}
public function setup(): void
{
$this->setDir($directory)->setFileName($fileName);
}
#endregion
}
Now, the phpdotenv version
Services/DotenvParser/phpdotenv.php
namespace MyVendor\Services\DotenvParser;
use Dotenv\Dotenv;
use InvalidArgumentException;
use Dotenv\Dotenv;
use InvalidArgumentException;
class phpdotenv implements DotenvParserInterface
{
public function parse(string $directory, string $fileName = ".env"): array
{
try{
Dotenv::createMutable($directory, $fileName)->load();
}catch(\Dotenv\Exception\InvalidPathException $e){
throw new InvalidArgumentException($e->getMessage(), 0, $e);
}
$result = $_ENV;
$_ENV = []; //Hehe
return $result;
}
}
Our service which we made from extending our Service class
Services/DotenvParser/DotenvParserService.php
namespace MyVendor\Services\DotenvParser;
use MyVendor\Service;
class DotenvParserService extends Service
{
// We can do this to make type hinting for ourselves
public static function get(): DotenvParserInterface
{
return parent::get();
}
protected static function instantiate(): DotenvParserInterface
{
$year = 2022;
// Some condition, to return one or another
if($year < 2022){
$dep = new \MyVendor\Services\DotenvParser\Dependency("ORANGE", true);
return new OldDotenvCode($dep);
}
return new phpdotenv();
}
}
And now, we can use it like this:
$dotenvparser = \MyVendor\Services\DotenvParser\DotenvParserService::get();
$result = $dotenvparser->parse(__DIR__);
var_dump($result);
// Outputs an array of our environment variables, yey!
We can also write tests for our services to see if anything breaks:
namespace MyVendorTest\Services\DotenvParser;
use InvalidArgumentException;
use MyVendor\Services\DotenvParser\DotenvParserInterface;
use MyVendor\Services\DotenvParser\DotenvParserService;
final class DotenvParserServiceTest extends \PHPUnit\Framework\TestCase
{
public function doesInstantiate(): void
{
$testParser = DotenvParserService::get();
$this->assertInstanceOf(DotenvParserInterface::class, $testParser);
}
public function testWorksFromValidDirNFile(): void
{
// The actual contents of a .env file
$testArray = [
"DEV_MODE" => "TRUE",
"BASE_HREF" => "http://localhost:8080/"
];
$testParser = DotenvParserService::get();
// phpdotenv loads every parent .env too and i was having none of it for this quick demonstration
$result = $testParser->parse(__DIR__."/../../../", ".env");
$this->assertEquals($testArray, $result);
}
public function testSetupFromInvalidDir(): void
{
$this->expectException(InvalidArgumentException::class);
$testParser = DotenvParserService::get();
$testParser->parse("i_am_a_dir_which_does_not_exist");
}
public function testSetupFromInvalidFile(): void
{
$this->expectException(InvalidArgumentException::class);
$testParser = DotenvParserService::get();
$testParser->parse(__DIR__, ".notenv");
}
}
So this ended up quite lenghty, but after having that Service class, you basically only need: An interface, at least one implementation of that interface, and a service class which instantiates an implementation of that interface, and optionally some tests for it. And, you can even do dependency injection with it (??) (circular dependencies would get us stuck in an endless loop), like this:
protected static function instantiate(): FooInterface
{
//BarService & AcmeService are extending our Service class
return new FooInterface(BarService::get(), AcmeService::get(), "ORANGE JUICE")
}
I am ready to absorb massive amounts of information
What other things Laravel's Service providers & containers do than i am aware of?
Why and how is it better than a simpler version, like this one?
Does my version really achieve at least those 4 key points i mentioned in the start?
I have an Exception somewhere in my service/ folder, and Symfony is trying to autowire it :
Cannot autowire service
"App\Service\Order\Exception\StripeRequiresActionException": argument
"$secretKey" of method "__construct()" is type-hinted "string", you
should configure its value explicitly.
This is my class :
class StripeRequiresActionException extends \Exception
{
/**
* #var string
*/
protected $secretKey;
public function __construct(string $secretKey)
{
parent::__construct();
$this->secretKey = $secretKey;
}
/**
* #return string
*/
public function getSecretKey(): string
{
return $this->secretKey;
}
}
I don't want it to be autowired. Is there an easy way to prevent this class to be loaded by the DI, with an annotation for example? I know I can exclude this class in my yaml configuration, but I don't want to do that because I find this ugly and harder to maintain.
Maybe you could exclude all exceptions, no matter where they are.
If all your exceptions follow the pattern you show in your question, you could do something similar to:
App\:
resource: '../src/*'
exclude: ['../src/{Infrastructure/Symfony,Domain,Tests}', '../src/**/*Exception.php']
This comes directly from a project I have open right here. The default exclude for Symfony looks somewhat different. But the important bit would be to add the pattern *Exception.php to the excluded files.
This is simpler to maintain than an annotation, even if an annotation were possible (which I believe it's not). Keeps the configuration all in a same place, you can create new exceptions without having to change configuration or add unnecessary code.
Even if I agree that in your particular case the cleanest way is to do what yivi suggested, I think I have a more generic solution that could suit more cases.
In my case I have a PagesScanner service that returns PageResult objects, both are several level deep into an autowired directory.
Excluding the class like suggested is a pain and will make the yaml unreadable quickly as the number of exceptions increases.
So I created a new compiler pass that searches for an #IgnoreAutowire annotation on each class under the App/ folder :
<?php
namespace App\DependencyInjection\Compiler;
use App\Annotation\IgnoreAutowire;
use Doctrine\Common\Annotations\AnnotationReader;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class RemoveUnwantedAutoWiredServicesPass implements CompilerPassInterface
{
/**
* {#inheritdoc}
*/
public function process(ContainerBuilder $container)
{
$annotationReader = new AnnotationReader();
$definitions = $container->getDefinitions();
foreach ($definitions as $fqcn => $definition) {
if (substr($fqcn, 0, 4) === 'App\\') {
try {
$refl = new \ReflectionClass($fqcn);
$result = $annotationReader->getClassAnnotation($refl, IgnoreAutowire::class);
if ($result !== null) {
$container->removeDefinition($fqcn);
}
} catch (\Exception $e) {
// Ignore
}
}
}
}
}
This way all I have to do is to add the annotation to classes I don't want to be autowired:
<?php
namespace App\Utils\Cms\PagesFinder;
use App\Annotation\IgnoreAutowire;
/**
* #IgnoreAutowire()
*/
class PageResult
{
[...]
}
Another good thing about this approch is you can even have parameters in the class constructor without any error because the actual autowiring thing is done after the compiler pass.
BTW code for php8 attributes:
CompilerPass
<?php
namespace App\DependencyInjection\Compiler;
use App\Annotation\IgnoreAutowire;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
final class RemoveUnwantedAutoWiredServicesPass implements CompilerPassInterface
{
/**
* {#inheritdoc}
*/
public function process(ContainerBuilder $container)
{
$definitions = $container->getDefinitions();
foreach ($definitions as $fqcn => $definition) {
if (str_starts_with($fqcn, 'App\\')) {
try {
$refl = new \ReflectionClass($fqcn);
$attribute = $refl->getAttributes(IgnoreAutowire::class)[0] ?? null;
if ($attribute !== null) {
$container->removeDefinition($fqcn);
}
} catch (\Exception $e) {
// Ignore
}
}
}
}
}
Attribute
<?php
namespace App\Annotation;
use Attribute;
/**
* Annotation class for #IgnoreAutowire().
*
* #Annotation
* #Target({"CLASS"})
*/
#[Attribute(Attribute::TARGET_CLASS)]
class IgnoreAutowire
{
}
You can also disable autoconfigure for only this class in config/services.yaml:
App\Service\Order\Exception\StripeRequiresActionException:
autoconfigure: false
Symfony won't add this class to the DI.
I have a Sumfony 4.3 command that processes some data and loops through a number of "processors" to do the processing. The code uses a factory (autowired) which then instantiates the command.
use App\Entity\ImportedFile;
use App\Service\Processor\Processor;
class Factory implements FactoryInterface
{
/** #var array */
private $processors;
/** #var TestClausesInterface */
private $testClauses;
private $em;
private $dataSetProvider;
private $ndviFromNasaService;
private $archivalHashService;
private $mailer;
private $projectDir;
public function __construct(
TestClausesInterface $testClauses,
ValidProcessorList $processors,
EntityManagerInterface $em,
DataSetProvider $dataSetProvider,
NDVIFromNasaService $ndviFromNasaService,
ArchivalHashService $archivalHashService,
\Swift_Mailer $mailer,
$projectDir)
{
$this->processors = $processors;
$this->testClauses = $testClauses;
$this->em = $em;
$this->dataSetProvider = $dataSetProvider;
$this->ndviFromNasaService = $ndviFromNasaService;
$this->archivalHashService = $archivalHashService;
$this->mailer = $mailer;
$this->projectDir = $projectDir;
}
public function findProcessorForFile(ImportedFile $file)
{
...
if ($found){
$candidates = $this->recursive_scan( $this->projectDir.'/src/Processor');
foreach ($candidates as $candidate){
if (substr($candidate,0,strlen('Helper')) === 'Helper'){
continue;
}
try {
$candidate = str_replace($this->projectDir.'/src/Processor/', '', $candidate);
$candidate = str_replace('/','\\', $candidate);
$testClassName = '\\App\\Processor\\'.substr( $candidate, 0, -4 );
/* #var Processor $test */
if (!strstr($candidate, 'Helper')) {
$test = new $testClassName($this->testClauses, $this->em, $this->dataSetProvider, $this->ndviFromNasaService, $this->archivalHashService, $this->mailer, $this->projectDir);
}
However I still have to:
autowire all arguments both in the Factory and Processor top class
pass all arguments in correct order to the Processor
I have around 70 subclasses of Processor. All of them use EntityInterface, but only a couple use SwiftMailer and the other dependencies.
As I am adding services to be used only by a few Processors, I am looking for a way to autowire these arguments only at the Processor level. Ideally, also without adding service definitions to services.yml
In summary, I would like to be able to add a dependency to any subclass of Processor, even if it is a parent class of other subclasses and have the dependency automatically injected.
There is much it is not immediately obvious in your code, but the typical way to resolve this is by using a "service locator". Docs.
Let's imagine you have several services implementing the interface Processor:
The interface:
interface Processor {
public function process($file): void;
}
Couple implementation:
class Foo implements Processor
{
public function __construct(DataSetProvider $dataSet, ArchivalHashService $archivalHash, \Swift_Mailer $swift) {
// initialize properties
}
public function process($file) {
// process implementation
}
public static function getDefaultIndexName(): string
{
return 'candidateFileOne';
}
}
Couple implementations:
class Bar implements Processor
{
public function __construct(\Swift_Mailer $swift, EntityManagerInterface $em) {
// initialize properties
}
public function process($file) {
// process implementation
}
public static function getDefaultIndexName(): string
{
return 'candidateFileTwo';
}
}
Note that each of the processors have completely different dependencies, and can be auto-wired directly, and that each of them has a getDefaultIndexName() method.
Now we'll "tag" all services implementing the Processor interface:
# services.yaml
services:
# somewhere below the _defaults and the part where you make all classes in `src` available as services
_instanceof:
App\Processor:
tags:
- { name: "processor_services", default_index_method: 'getDefaultIndexName' }
Attention here: The documentation says that if you define a public static function getDefaultIndexName() it will be picked by default. But I've found this not to be working at the moment. But if you define the default_index_method you can wire it to a method of your choice. I'm keeping the getDefaultIndexName for the time being, but you can pick something of your own choice.
Now, if you need this processes in a console command, for example:
use Symfony\Component\DependencyInjection\ServiceLocator;
class MyConsoleCommand
{
private ServiceLocator $locator;
public function __construct(ServiceLocator $locator)
{
$this->locator = $locator;
}
}
To inject the service locator you would do:
#services.yaml
services:
App\HandlerCollection:
arguments: [!tagged_locator { tag: 'processor_services' } ]
And to fetch any of the processors from the service locator you would do:
$fooProcessor = $this->locator->get('candidateFileOne');
$barProcessor = $this->locator->get('candidateFileTwo');
Summping up, basically what you need is:
Define a shared interface for the processors
Use that interface to tag all the processor services
Define a getDefaultIndexName() for each processor, which helps you match files to processors.
Inject a tagged service locator in the class that need to consume this services
And you can leave all services auto-wired.
Note: You could use an abstract class instead of an interface, and it would work the same way. I prefer using an interface, but that's up to you.
For completion sake, here is a repo with the above working for Symfony 4.3.
I got two entities. One is a WebshopItem entity, the other one is a WebshopPrice entity.
Each time, you are creating a WebshopItem, you are also filling in 3 WebshopPrices. The WebshopPrices are 3 currencies (EUR, USD and GBP).
Based on the currency you selected (and is saved in your session) I want to display the currency you selected. So, if you picked EUR, I of course want to display the EUR price.
What's the general way of doing this in symfony? Should I use a twig extension which returns the price from the WebshopItem object, based on what's in your session? Should I already filter the WebshopPrices from the database?
Looking forward to your best solutions. Thanks!
Entity/WebshopItem.php
class WebshopItem
{
/**
* #var \Doctrine\Common\Collections\Collection
*/
private $prices;
etc....
}
Entity/WebshopItemPrice.php
class WebshopItemPrice
{
/**
* #var integer
*/
private $id;
/**
* #var string
*/
private $currency;
/**
* #var string
*/
private $price;
private $webshopItem;
}
UPDATE
You can use an entity listener too, but in that case you'll need to override the default resolver to get the session in your listener:
src/Your/GreatBundle/Resources/config/services.yml
doctrine.orm.default_entity_listener_resolver:
class: Your\GreatBundle\Listener\EntityListenerResolver
arguments: [#service_container]
src/Your/GreatBundle/Listener/EntityListenerResolver
namespace Your\GreatBundle\Listener;
use Doctrine\ORM\Mapping\EntityListenerResolver as EntityListenerResolverInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class EntityListenerResolver implements EntityListenerResolverInterface
{
private $instances = [];
private $container;
public function __construct(ContainerInterface $container)
{
$this->container = $container;
}
public function clear($className = null)
{
if ($className === null) {
$this->instances = [];
return;
}
if (isset($this->instances[$className = trim($className, '\\')])) {
unset($this->instances[$className]);
}
}
public function register($object)
{
if ( ! is_object($object)) {
throw new \InvalidArgumentException(sprintf('An object was expected, but got "%s".', gettype($object)));
}
$this->instances[get_class($object)] = $object;
}
public function resolve($className)
{
if (isset($this->instances[$className = trim($className, '\\')])) {
return $this->instances[$className];
}
// Here we are injecting the entire container to the listeners
return $this->instances[$className] = new $className($this->container);
}
}
You might listen to the Doctrine's postLoad event in a service injected with the user's session:
src/Your/GreatBundle/Resources/config/services.yml
services:
price.listener:
class: Your\GreatBundle\Listener\PriceListener
arguments: [#session]
tags:
- { name: doctrine.event_listener, event: postLoad }
src/Your/GreatBundle/Listener/PriceListener.php
namespace Your\GreatBundle\Listener\PriceListener;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Your\GreatBundle\Entity\WebshopItem;
class PriceListener
{
private $session;
public function __construct(SessionInterface $session)
{
$this->session = $session;
}
public function postLoad(LifecycleEventArgs $event)
{
$entity = $event->getEntity();
if ($entity instanceof WebshopItem) {
$currency = $this->session->get('currency', 'EUR');
$entity->setCurrency(currency);
}
}
}
src/Your/GreatBundle/Entity/WebshopItem.php
namespace Your\GreatBundle\Entity;
class WebshopItem
{
...
// You don't need to persist this...
private $currency = 'EUR';
public function setCurrency($currency)
{
$this->currency = $currency;
}
public function getPrice()
{
foreach ($this->prices as $price) {
if ($price->getCurrency() === $this->currency) {
return ($price->getPrice();
}
}
return null;
}
}
You can query WebshopItem joined with WebshopItemPrice WHERE WebshopItemPrice.currency = $variableFromSesion. For example:
$queryBuilder
->select('WebshopItem, WebshopItemPrice')
->leftJoin('WebshopItemPrice.prices', 'WebshopItemPrice')
->where('WebshopItemPrice.currency = :currency')
->setParameter('currency', $variableFromSesion)
Where $variableFromSesion is a current user currency stored in session. After executing that query (by getResult() ) you can get price by calling $webshopItem->getPrices() - this should return only one result
The trick is not about how to retrieve the data but what to use to do it. Since this depends on the Request object you should go with something service-ish. If you're sure you'll never never use this elsewhere, then go with the twig extension. If your backend code would use it too then use a service, say MyPricePickerService (and get that in the twig extension). Note on twig extension that I cannot find anything that warrants that a new instance is created for every scope, so upon every call to this operation you should use the injected Container (so not a cached Request, or a previous instance of MyPricePickerService)!
Currently I am trying to learn the Zend Framework and therefore I bought the book "Zend Framework in Action".
In chapter 3, a basic model and controller is introduced along with unit tests for both of them. The basic controller looks like this:
class IndexController extends Zend_Controller_Action
{
public function indexAction()
{
$this->view->title = 'Welcome';
$placesFinder = new Places();
$this->view->places = $placesFinder->fetchLatest();
}
}
Places is the model class that fetches the latest places from the database. What bugs me here: how should I test the IndexController in isolation? As the reference to the Places class is "hardcoded", I cant inject any stubs or mocks in IndexController.
What I would rather like to have is something like this:
class IndexController extends Zend_Controller_Action
{
private $placesFinder;
// Here I can inject anything: mock, stub, the real instance
public function setPlacesFinder($places)
{
$this->placesFinder = $places;
}
public function indexAction()
{
$this->view->title = 'Welcome';
$this->view->places = $this->placesFinder->fetchLatest();
}
}
The first code sample I posted is most definately NOT unit test friendly as IndexController cannot be tested in isolation. The second one is much better. Now I just need some way to inject the model instances into the controller objects.
I know that the Zend Framework per se has no component for dependency injection. But there are some good frameworks out there for PHP, can any be used together with Zend Framework? Or is there some other way to do this in Zend Framework?
Logic to models
First of all, it's worth to mention, that controllers should need only functional tests, though all the logic belongs to models.
My implementation
Here is an excerpt from my Action Controller implementation, which solves the following problems:
allows inject any dependency to actions
validates the action parameters, e.g. you may not pass array in $_GET when integer is expected
My full code allows also to generate canonical URL (for SEO or unique page hash for stats) based or required or handled action params. For this, I use this abstract Action Controller and custom Request object, but this is not the case we discuss here.
Obviously, I use Reflections to automatically determine action parameters and dependency objects.
This is a huge advantage and simplifies the code, but also has an impact in performance (minimal and not important in case of my app and server), but you may implement some caching to speed it up. Calculate the benefits and the drawbacks, then decide.
DocBlock annotations are becoming a pretty well known industry standard, and parsing it for evaluation purposes becomes more popular (e.g. Doctrine 2). I used this technique for many apps and it worked nicely.
Writing this class I was inspired by Actions, now with params! and Jani Hartikainen's blog post.
So, here is the code:
<?php
/**
* Enchanced action controller
*
* Map request parameters to action method
*
* Important:
* When you declare optional arguments with default parameters,
* they may not be perceded by optional arguments,
* e.g.
* #example
* indexAction($username = 'tom', $pageid); // wrong
* indexAction($pageid, $username = 'tom'); // OK
*
* Each argument must have #param DocBlock
* Order of #param DocBlocks *is* important
*
* Allows to inject object dependency on actions:
* #example
* * #param int $pageid
* * #param Default_Form_Test $form
* public function indexAction($pageid, Default_Form_Test $form = null)
*
*/
abstract class Your_Controller_Action extends Zend_Controller_Action
{
/**
*
* #var array
*/
protected $_basicTypes = array(
'int', 'integer', 'bool', 'boolean',
'string', 'array', 'object',
'double', 'float'
);
/**
* Detect whether dispatched action exists
*
* #param string $action
* #return bool
*/
protected function _hasAction($action)
{
if ($this->getInvokeArg('useCaseSensitiveActions')) {
trigger_error(
'Using case sensitive actions without word separators' .
'is deprecated; please do not rely on this "feature"'
);
return true;
}
if (method_exists($this, $action)) {
return true;
}
return false;
}
/**
*
* #param string $action
* #return array of Zend_Reflection_Parameter objects
*/
protected function _actionReflectionParams($action)
{
$reflMethod = new Zend_Reflection_Method($this, $action);
$parameters = $reflMethod->getParameters();
return $parameters;
}
/**
*
* #param Zend_Reflection_Parameter $parameter
* #return string
* #throws Your_Controller_Action_Exception when required #param is missing
*/
protected function _getParameterType(Zend_Reflection_Parameter $parameter)
{
// get parameter type
$reflClass = $parameter->getClass();
if ($reflClass instanceof Zend_Reflection_Class) {
$type = $reflClass->getName();
} else if ($parameter->isArray()) {
$type = 'array';
} else {
$type = $parameter->getType();
}
if (null === $type) {
throw new Your_Controller_Action_Exception(
sprintf(
"Required #param DocBlock not found for '%s'", $parameter->getName()
)
);
}
return $type;
}
/**
*
* #param Zend_Reflection_Parameter $parameter
* #return mixed
* #throws Your_Controller_Action_Exception when required argument is missing
*/
protected function _getParameterValue(Zend_Reflection_Parameter $parameter)
{
$name = $parameter->getName();
$requestValue = $this->getRequest()->getParam($name);
if (null !== $requestValue) {
$value = $requestValue;
} else if ($parameter->isDefaultValueAvailable()) {
$value = $parameter->getDefaultValue();
} else {
if (!$parameter->isOptional()) {
throw new Your_Controller_Action_Exception(
sprintf("Missing required value for argument: '%s'", $name));
}
$value = null;
}
return $value;
}
/**
*
* #param mixed $value
*/
protected function _fixValueType($value, $type)
{
if (in_array($type, $this->_basicTypes)) {
settype($value, $type);
}
return $value;
}
/**
* Dispatch the requested action
*
* #param string $action Method name of action
* #return void
*/
public function dispatch($action)
{
$request = $this->getRequest();
// Notify helpers of action preDispatch state
$this->_helper->notifyPreDispatch();
$this->preDispatch();
if ($request->isDispatched()) {
// preDispatch() didn't change the action, so we can continue
if ($this->_hasAction($action)) {
$requestArgs = array();
$dependencyObjects = array();
$requiredArgs = array();
foreach ($this->_actionReflectionParams($action) as $parameter) {
$type = $this->_getParameterType($parameter);
$name = $parameter->getName();
$value = $this->_getParameterValue($parameter);
if (!in_array($type, $this->_basicTypes)) {
if (!is_object($value)) {
$value = new $type($value);
}
$dependencyObjects[$name] = $value;
} else {
$value = $this->_fixValueType($value, $type);
$requestArgs[$name] = $value;
}
if (!$parameter->isOptional()) {
$requiredArgs[$name] = $value;
}
}
// handle canonical URLs here
$allArgs = array_merge($requestArgs, $dependencyObjects);
// dispatch the action with arguments
call_user_func_array(array($this, $action), $allArgs);
} else {
$this->__call($action, array());
}
$this->postDispatch();
}
$this->_helper->notifyPostDispatch();
}
}
To use this, just:
Your_FineController extends Your_Controller_Action {}
and provide annotations to actions, as usual (at least you already should ;).
e.g.
/**
* #param int $id Mandatory parameter
* #param string $sorting Not required parameter
* #param Your_Model_Name $model Optional dependency object
*/
public function indexAction($id, $sorting = null, Your_Model_Name $model = null)
{
// model has been already automatically instantiated if null
$entry = $model->getOneById($id, $sorting);
}
(DocBlock is required, however I use Netbeans IDE, so the DocBlock is automatically generated based on action arguments)
Ok, this is how I did it:
As IoC Framework I used this component of the symfony framework (but I didnt download the latest version, I used an older one I used on projects before... keep that in mind!). I added its classes under /library/ioc/lib/.
I added these init function in my Bootstrap.php in order to register the autoloader of the IoC framework:
protected function _initIocFrameworkAutoloader()
{
require_once(APPLICATION_PATH . '/../library/Ioc/lib/sfServiceContainerAutoloader.php');
sfServiceContainerAutoloader::register();
}
Next, I made some settings in application.ini which set the path to the wiring xml and allow to disable automatic dependency injection e. g. in unit tests:
ioc.controllers.wiringXml = APPLICATION_PATH "/objectconfiguration/controllers.xml"
ioc.controllers.enableIoc = 1
Then, I created a custom builder class, which extends sfServiceContainerBuilder and put it under /library/MyStuff/Ioc/Builder.php. In this test project I keep all my classes under /library/MyStuff/.
class MyStuff_Ioc_Builder extends sfServiceContainerBuilder
{
public function initializeServiceInstance($service)
{
$serviceClass = get_class($service);
$definition = $this->getServiceDefinition($serviceClass);
foreach ($definition->getMethodCalls() as $call)
{
call_user_func_array(array($service, $call[0]), $this->resolveServices($this->resolveValue($call[1])));
}
if ($callable = $definition->getConfigurator())
{
if (is_array($callable) && is_object($callable[0]) && $callable[0] instanceof sfServiceReference)
{
$callable[0] = $this->getService((string) $callable[0]);
}
elseif (is_array($callable))
{
$callable[0] = $this->resolveValue($callable[0]);
}
if (!is_callable($callable))
{
throw new InvalidArgumentException(sprintf('The configure callable for class "%s" is not a callable.', get_class($service)));
}
call_user_func($callable, $service);
}
}
}
Last, I created a custom controller class in /library/MyStuff/Controller.php which all my controllers inherit from:
class MyStuff_Controller extends Zend_Controller_Action {
/**
* #override
*/
public function dispatch($action)
{
// NOTE: the application settings have to be saved
// in the registry with key "config"
$config = Zend_Registry::get('config');
if($config['ioc']['controllers']['enableIoc'])
{
$sc = new MyStuff_Ioc_Builder();
$loader = new sfServiceContainerLoaderFileXml($sc);
$loader->load($config['ioc']['controllers']['wiringXml']);
$sc->initializeServiceInstance($this);
}
parent::dispatch($action);
}
}
What this basically does is using the IoC Framework in order to initialize the already created controller instance ($this). Simple tests I did seemed to do what I want... let´s see how this performs in real life situations. ;)
It´s still monkey patching somehow, but the Zend Framework doesn´t seem to provide a hook where I can create the controller instance with a custom controller factory, so this is the best I came up with...
I'm currently working on the same question, and after deep research I've decide to use Symfony Dependency Injection component. You can get good info from official website http://symfony.com/doc/current/book/service_container.html.
I've build custom getContainer() method in bootstrap, which resturns now service container, and it simply can be used in controllers like
public function init()
{
$sc = $this->getInvokeArg('bootstrap')->getContainer();
$this->placesService = $sc->get('PlacesService');
}
Here you can find how to do that http://blog.starreveld.com/2009/11/using-symfony-di-container-with.html. But I changed ContainerFactory, because of using Symfony2 component, instead of first version.
You could also just use the PHP-DI ZF bridge: http://php-di.org/doc/frameworks/zf1.html
I know this question is really old but it pops up rather high in search engines when looking for DI in ZF1 so I thought I'd add a solution that doesn't require you to write it all by yourself.
With the Service Manager at Zend Framework 3.
Official Documentation:
https://zendframework.github.io/zend-servicemanager/
Dependencies at your Controller are usually be injected by DI Constructor injector.
I could provide one example, that inject a Factory responsible to create the ViewModel instance into the controller.
Example:
Controller
`
class JsonController extends AbstractActionController
{
private $_jsonFactory;
private $_smsRepository;
public function __construct(JsonFactory $jsonFactory, SmsRepository $smsRepository)
{
$this->_jsonFactory = $jsonFactory;
$this->_smsRepository = $smsRepository;
}
...
}
Creates the Controller
class JsonControllerFactory implements FactoryInterface
{
/**
* #param ContainerInterface $serviceManager
* #param string $requestedName
* #param array|null $options
* #return JsonController
*/
public function __invoke(ContainerInterface $serviceManager, $requestedName, array $options = null)
{
//improve using get method and callable
$jsonModelFactory = new JsonFactory();
$smsRepositoryClass = $serviceManager->get(SmsRepository::class);
return new JsonController($jsonModelFactory, $smsRepositoryClass);
}
}
`
Complete example at https://github.com/fmacias/SMSDispatcher
I hope it helps someone