How events work in Yii2 - php

I have been trying to learn how event works in Yii from the Yii Guide. I have found there are most important things: Event Handlers, Attaching Event Handlers and Triggering Events. I have read entire the article properly. But I don't understand how to implement these three things properly. How to see the effect of it's implementation. I have extended component class as:
namespace app\components;
use yii\base\Component;
use yii\base\Event;
use yii\web\View;
class Foo extends Component{
const EVENT_HELLO = 'hello';
public function bar()
{
$this->trigger(self::EVENT_HELLO);
}
}
I do not understand what is the next part to do. Where I should write the Attaching Event Handlers. Can you help me someone, at least I can see a simple output using event.

You may create init() method in model:
public function init()
{
$this->on(Event::ACTION_ADD, ['app\models\Event', 'sendInLog']);
$this->on(Event::ACTION_DELETE, ['app\models\Event', 'sendInLog']);
$this->on(Event::ACTION_UPDATE, ['app\models\Event', 'sendInLog']);
}
In initialize events in second parameter you may use current model or set other model. If you want use current model set like that:
[$this, 'sendInLog']
sendInLog it is method in model. In method sendInLog one parameter it is $event. This is object \yii\base\Event. In property $event->name - it is event name. In property $event->sender - it is model class from trigger event.
In my class app\models\Event like that:
namespace app\models;
class Event extends Component
{
const ACTION_ADD = 1;
const ACTION_DELETE = 2;
const ACTION_UPDATE = 3;
const TYPE_PROJECT = 10;
const TYPE_BIDS = 20;
const TYPE_BIDS_DATA = 30;
/**
* #param $event
*/
public static function sendInLog($event)
{
/** #var \yii\base\Event $event */
/** #var ActiveRecord $event->sender */
$userId = Yii::$app->user->id;
$model = new Logs([
'type' => $event->sender->getType(),
'action' => $event->name,
'id_user' => $userId,
'old_data' => Json::encode($event->sender->attributes),
'new_data' => Json::encode($event->sender->oldAttributes),
]);
$model->save();
}
}
Run trigger like that:
public function afterDelete()
{
$this->trigger(Event::ACTION_DELETE);
parent::afterDelete();
}
Or
public function actionView()
{
$this->trigger(Event::ACTION_VIEW);
$this->render(...);
}
EDIT:
For example. If you want run trigger after delete, insert, update. You may use trigger in afterDelete, afterSave in model. If you want run trigger in controller run trigger like that:
public function actionCreate()
{
$model = new Bids();
$model->id_project = Yii::$app->request->get('projectId');
$fieldsDefaults = BidsFieldsDefaults::find()->orderBy(['order' => SORT_ASC])->all();
if ($model->load(Yii::$app->request->post()) && $model->save()) {
$model->trigger(Event::ACTION_ADD);
return $this->redirect(['view', 'id' => $model->id]);
} else {
return $this->render('create', [
'model' => $model,
'fieldsDefaults' => $fieldsDefaults
]);
}
}
I show you two different ways to run trigger. Which one to use is up to you :)

Related

Linking models to parent in Laravel constructor

Maybe I'm searching it all wrong but I haven't been able to figure out an answer.. Say I have a model Building, which always has n Floor(s)
I would like to write a constructor for Building, in which I could specify a number of Floor(s) to be created. The problem is that I can't link back a Floor to the Building because when the constructor for Building is being called, it doesn't have a primary key yet...
Basically, my code looks like this but doesn't work:
class Building extends Model {
public function __construct($nbFloors) {
for($i=0; $i<$nbFloors; $i++) {
$foo = new Floor();
$foo->building_id = $this->id;
$foo->save();
}
}
}
What would be the correct solution to achieve something like that?
The primary key will never be available in the constructor and your constructor's definition is not compatible with Model which expects an array of attributes as the first argument.
You're performing too much logic in your constructor, a constructor is meant to just instantiate an object and its dependencies, not perform business logic. By doing this in your constructor, you're actually going to be attempting to create new floors EVERY time your Model is instantiated which includes when your model is retrieved from the database.
I'd recommend adding a new method like:
public function createWithFloors($n) {
$this->save();
...
}
Now, you can use the model as it's expected and call the create method:
$building = new Building(['name' => 'Empire State']);
$building->createWithFloors(102);
Besides the solutions already suggested, you could create an event that is fired when a Building is created. A listener could then store your Floors. For event reference, have a look at the documentation.
First, create an event called BuildingCreated with php artisan make:event BuildingCreated and use below code:
namespace App\Events;
use App\Building;
use Illuminate\Queue\SerializesModels;
class BuildingCreated extends Event
{
use SerializesModels;
public $building;
public function __construct(Building $building)
{
$this->building = $building;
}
}
Then, register the event within your Building model:
use App\Events\BuildingCreated;
class Building
{
protected $dispatchesEvents = [
'created' => BuildingCreated::class,
];
}
Next, you will need a listener that creates the floors. Create it with php artisan make:listener AddFloorsToNewBuilding and adapt it as you need:
namespace App\Listeners;
use App\Building;
use App\Events\BuildingCreated;
class AddFloorsToNewBuilding
{
public function handle(BuildingCreated $event)
{
$floors = ...;
$event->building->floors()->saveMany($floors);
$event->building->save();
}
}
Lastly, have the listener listen for the event by adding it to the $listen array in the EventServiceProvider:
class EventServiceProvider
{
protected $listen = [
\App\Events\BuildingCreated::class => [
\App\Listeners\AddFloorsToNewBuilding::class,
],
];
}
since, you can't bind Floor to a building that is not created yet, you should make the "new floors number" an attribute of the Building instance. Then you overload the save method to create the new floors.
class Building extends Model {
/** number of floors to be created on save
* #var int
*/
private $newFloorsCount;
/**
* Building constructor.
* #param array $attributes
* #param int $nbFloors
*/
public function __construct(array $attributes = [], $nbFloors = 0) {
parent::__construct($attributes);
$this->newFloorsCount = $nbFloors;
}
/**
* #param array $options
* #return bool
*/
public function save(array $options = [])
{
$return = parent::save($options);
for($i=0; $i<$this->newFloorsCount; $i++) {
$foo = new Floor();
$foo->building_id = $this->id;
$foo->save();
}
return $return;
}
}
now you can just do
$building = new Building([],5);
$building->save();

How to attach event listener via configuration instead of module bootstrap?

In ZF3 you would normally attach your event listener for the MvcEvent's in your module's Module.php like so:
<?php
namespace MyModule;
class Module
{
public function onBootstrap(MvcEvent $event)
{
$eventManager = $event->getApplication()->getEventManager();
$eventManager->attach(MvcEvent::EVENT_DISPATCH, function(MvcEvent $event) {
// Do someting...
});
}
}
Now there are two typical situations where your Module.php can grow big:
Your module has to handle multiple (or even all) MvcEvent's and maybe even treat them in different ways.
Your module has to perform multiple actions on a single MvcEvent.
What I'd like to be able to do is to specify a class name in my module.config.php along with one or multiple MvcEvent names to keep my Module.php nice and clean.
Is there a way to do this in Zend Framework 3?
#Nukeface has a great example but it does not directly answer my specific question.
To answer my own question:
This is possible with the use of listeners. A listener can be configured in the configuration files but it cannot be mapped to an event directly from the configuration alone.
It is possible to check for a specific setting in the configuration and determine what classes to map to what events. Even MvcEvents can be mapped this way.
Here's how to set it up:
1. The listener
We want to listen to multiple MvcEvents with one simple class. Note the class it extends.
namespace Demo\Listener;
class MyListener extends EventClassMapListener
{
public function handleEvent(MvcEvent $event)
{
// Do something
\Zend\Debug\Debug::dump($event->getName());
}
}
2. The abstract listener class
The above class needs a bit more body but that can be provided by the abstract listener class:
namespace Demo\Listener;
abstract class EventClassMapListener implements ListenerAggregateInterface
{
private $configuration;
public function __construct(array $configuration)
{
$this->configuration = $configuration;
}
public function attach(EventManagerInterface $events, $priority = 1)
{
$sharedManager = $events->getSharedManager();
foreach ($this->configuration as $identifier => $settings) {
foreach ($settings as $event => $configPriority) {
$sharedManager->attach($identifier, $event, [$this, 'handleEvent'], $configPriority ?: $priority);
}
}
}
public function detach(EventManagerInterface $events)
{
// Do the opposite of attach
}
abstract public function handleEvent(MvcEvent $event);
}
3. The factory
Now we need a factory that we can reuse for all our classes that need to listen to multiple events:
namespace Demo\Factory\Listener;
class EventClassmapListenerFactory implements FactoryInterface
{
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
$globalConfiguration = $container->get('config');
$configuration = [];
if (array_key_exists('event_classmap', $globalConfiguration)
&& array_key_exists($requestedName, $globalConfiguration['event_classmap'])
) {
$configuration = $globalConfiguration['event_classmap'][$requestedName];
}
return new $requestedName($configuration);
}
}
4. Configuration
In your module.config.php:
'service_manager' => [
'factories' => [
Listener\MyListener::class => Factory\Listener\EventClassmapListenerFactory::class,
],
],
'listeners' => [
Listener\MyListener::class,
],
'event_classmap' => [
// Name of the class that needs to listen to events
Listener\MyListener::class => [
// Identifier
\Zend\Mvc\Application::class => [
// List of event names and priorities
MvcEvent::EVENT_BOOTSTRAP => 1,
],
// Another identifier
MyEventEmitterClass::class => [
MyEventEmitterClass::EVENT_ONE,
MyEventEmitterClass::EVENT_TWO,
MyEventEmitterClass::EVENT_THREE,
],
],
],
Conclusion:
Although it might not really refined, I really like this idea. It is now fairly easy to add another listener and make it listen to a list of events from one or more emitters.
My opinion after some research
A listener itself should state what it wants to listen to, to keep things strict. Putting that information in a configuration file might result in a more complicated situation when it is not needed.
You need a few things for Listener classes:
Events
Listeners
Handlers
Factories
Config
Now, 2 & 3 are usually in the same class as you would usually have a Listener class for a specific purpose. Such as "Listen for Rocket launch and steer Rocket to Mars".
As such, you would need to "create" these "events" to listen for somewhere. Such as a DemoEvents class!
namespace Demo\Event;
use Zend\EventManager\Event;
class DemoEvent extends Event
{
const THE_STRING_TO_LISTEN_FOR = 'rocket.ready.for.launch';
const ANOTHER_STRING_TO_LISTEN_FOR = 'rocket.steer.to.mars';
}
Now that we have "events", we need to "listen" for them. For that we need a Listener. Because I'm limiting this to 1 example, the Handler (function(-ality) to be executed when the "event" we're "listening" for is "heard") will be in the same class.
namespace Demo\Listener;
use Demo\Event\DemoEvent;
use Zend\EventManager\Event;
use Zend\EventManager\EventManagerInterface;
use Zend\EventManager\ListenerAggregateInterface;
class DemoListener implements ListenerAggregateInterface
{
/**
* #var array
*/
protected $listeners = [];
/**
* #param EventManagerInterface $events
*/
public function detach(EventManagerInterface $events)
{
foreach ($this->listeners as $index => $listener) {
if ($events->detach($listener)) {
unset($this->listeners[$index]);
}
}
}
/**
* #param EventManagerInterface $events
*/
public function attach(EventManagerInterface $events, $priority = 1)
{
$sharedManager = $events->getSharedManager();
$sharedManager->attach(Demo::class, DemoEvent::THE_STRING_TO_LISTEN_FOR, [$this, 'doSomethingOnTrigger'], -10000);
}
/**
* Apart from triggering specific Listener function and de-registering itself, it does nothing else. Add your own functionality
*
* #param Event $event
*/
public function doSomethingOnTrigger(Event $event)
{
// Gets passed along parameters from the ->trigger() function elsewhere
$params = $event->getParams();
$specificClass = $params[SpecificClass::class];
// Do something useful here
$specificClass->launchRocketIntoOrbit();
// Detach self to prevent running again
$specificClass->getEventManager()->getSharedManager()->clearListeners(get_class($specificClass), $event->getName());
// NOTE: USE THIS TRIGGER METHODOLOGY ELSEWHERE USING THE STRING FROM THE ATTACH() FUNCTION TO TRIGGER THIS FUNCTION
// Trigger events specific for the Entity/class (this "daisy-chains" events, allowing for follow-up functionality)
$specificClass->getEventManager()->trigger(
DemoEvent::ANOTHER_STRING_TO_LISTEN_FOR,
$specificClass ,
[get_class($specificClass) => $specificClass ] // Params getting passed along
);
}
}
Excellent. We now have a events, a listener and a handler. We just need a factory to create this class when needed.
namespace Demo\Factory;
use Demo\Listener;
use Interop\Container\ContainerInterface;
use Zend\ServiceManager\Factory\FactoryInterface;
class DemoListenerFactory implements FactoryInterface
{
/**
* #param ContainerInterface $container
* #param string $requestedName
* #param array|null $options
* #return object|DemoListener
* #throws \Psr\Container\ContainerExceptionInterface
* #throws \Psr\Container\NotFoundExceptionInterface
*/
public function __invoke(ContainerInterface $container, $requestedName, array $options = null)
{
// If you're implementation of the Listener has any requirements, load them here and add a constructor in the DemoListener class
return new DemoListener();
}
}
Lastly, we need some config. Obviously we need to register the Listener + Factory combination. Let's do that first.
namespace Demo;
use Demo\Listener\DemoListener;
use Demo\Listener\DemoListenerFactory;
'service_manager' => [
'factories' => [
DemoListener::class => DemoListenerFactory::class,
],
],
Now for a little known bit of config to make sure that the Listener gets registered as a Listener:
'listeners' => [
DemoListener::class
],
Yep, that's it.
Make sure to add both of these bits of config at the first level of config, they're siblings.

how can I pass a variable to partial navigation script in zf2?

I've made a global variable in bootstrap of Module.php
public function setCashServiceToView($event) {
$app = $event->getParam('application');
$cashService = $app->getServiceManager()->get('Calculator/Service/CashServiceInterface');
$viewModel = $event->getViewModel();
$viewModel->setVariables(array(
'cashService' => $cashService,
));
}
public function onBootstrap($e) {
$app = $e->getParam('application');
$app->getEventManager()->attach(\Zend\Mvc\MvcEvent::EVENT_RENDER, array($this, 'setCashServiceToView'), 100);
}
I can use it inside of my layout.phtml as
$this->cashService;
But I need this variable to use in my partial script of navigation menu, which I call in layout.phtml:
echo $this->navigation('navigation')
->menu()->setPartial('partial/menu')
->render();
?>
How can I use it inside of my partial/menu.phtml? And may be there is a better way, than to declare it in onBootstrap function?
Thank you for your answers. I decided to make an extended class of \Zend\View\Helper\Navigation\Menu to provide there a property of cashService. However I receive an error:'Zend\View\Helper\Navigation\PluginManager::get was unable to fetch or create an instance for Calculator\Service\CashServiceInterface'.
I need this service to display navigation menu. Seems weird, but that's true. I display some diagram in it, using the data, which I get from the service. So why do I have the error?
I added to module.config.php
'navigation_helpers' => array(
'factories' => array(
'mainMenu' => 'Calculator\View\Helper\Factory\MainMenuFactory'
),
MainMenuFactory:
namespace Calculator\View\Helper\Factory;
use Zend\ServiceManager\FactoryInterface;
use Zend\ServiceManager\ServiceLocatorInterface;
use Calculator\View\Helper\Model\MainMenu;
Class MainMenuFactory implements FactoryInterface {
/**
* Create service
*
* #param ServiceLocatorInterface $serviceLocator
* #return mixed
*/
public function createService(ServiceLocatorInterface $serviceLocator) {
return new MainMenu(
$serviceLocator->get('Calculator\Service\CashServiceInterface')
);
}
P.S: CashServiceInterface is an alias to CashServiceFactory
You could remove the event listener and use a custom view helper to access the service in the view.
namespace Calculator\View\Helper;
use Zend\View\Helper\AbstractHelper;
class CashService extends AbstractHelper
{
protected $cashService;
public function __construct(CashServiceInterface $cashService)
{
$this->cashService = $cashService;
}
public function __invoke()
{
return $this->cashService;
}
}
Create a factory.
namespace Calculator\View\Helper;
class CashServiceFactory
{
public function __invoke($viewPluginManager)
{
$serviceManager = $viewPluginManager->getServiceLocator();
$cashService = $serviceManager->get('Calculator\\Service\\CashServiceInterface');
return new CashService($cashService);
}
}
Register the new helper in moudle.config.php.
'view_helpers' => [
'factories' => [
'CashService' => 'Calculator\View\Helper\CashServiceFactory',
],
],
Then you can use the plugin in all view scripts.
$cashService = $this->cashService();

Mocking class parameter that returns a mock

I am new to unit testing and trying to test a controller method in Laravel 5.1 and Mockery.
I am trying to test a registerEmail method I wrote, below:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Response;
use Mailchimp;
use Validator;
/**
* Class ApiController
* #package App\Http\Controllers
*/
class ApiController extends Controller
{
protected $mailchimpListId = null;
protected $mailchimp = null;
public function __construct(Mailchimp $mailchimp)
{
$this->mailchimp = $mailchimp;
$this->mailchimpListId = env('MAILCHIMP_LIST_ID');
}
/**
* #param Request $request
* #return \Illuminate\Http\JsonResponse
*/
public function registerEmail(Request $request)
{
$this->validate($request, [
'email' => 'required|email',
]);
$email = $request->get('email');
try {
$subscribed = $this->mailchimp->lists->subscribe($this->mailchimpListId, [ 'email' => $email ]);
//var_dump($subscribed);
} catch (\Mailchimp_List_AlreadySubscribed $e) {
return Response::json([ 'mailchimpListAlreadySubscribed' => $e->getMessage() ], 422);
} catch (\Mailchimp_Error $e) {
return Response::json([ 'mailchimpError' => $e->getMessage() ], 422);
}
return Response::json([ 'success' => true ]);
}
}
I am attempting to mock the Mailchimp object to work in this situation.
So far, my test looks as follows:
<?php
use Illuminate\Foundation\Testing\WithoutMiddleware;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTransactions;
class HomeRouteTest extends TestCase
{
use WithoutMiddleware;
public function testMailchimpReturnsDuplicate() {
$listMock = Mockery::mock('Mailchimp_Lists')
->shouldReceive('subscribe')
->once()
->andThrow(\Mailchimp_List_AlreadySubscribed::class);
$mailchimp = Mockery::mock('Mailchimp')->lists = $listMock;
$this->post('/api/register-email', ['email'=>'duplicate#email.com'])->assertJson(
'{"mailchimpListAlreadySubscribed": "duplicate#email.com is already subscribed to the list."}'
);
}
}
I have phpUnit returning a failed test.
HomeRouteTest::testMailchimpReturnsDuplicate
Mockery\Exception\InvalidCountException: Method subscribe() from Mockery_0_Mailchimp_Lists should be called exactly 1 times but called 0 times.
Also, if I assert the status code is 422, phpUnit reports it is receiving a status code 200.
It works fine when I test it manually, but I imagine I am overlooking something fairly easy.
I managed to solve it myself. I eventually moved the subscribe into a seperate Job class, and was able to test that be redefining the Mailchimp class in the test file.
class Mailchimp {
public $lists;
public function __construct($lists) {
$this->lists = $lists;
}
}
class Mailchimp_List_AlreadySubscribed extends Exception {}
And one test
public function testSubscribeToMailchimp() {
// create job
$subscriber = factory(App\Models\Subscriber::class)->create();
$job = new App\Jobs\SubscribeToList($subscriber);
// set up Mailchimp mock
$lists = Mockery::mock()
->shouldReceive('subscribe')
->once()
->andReturn(true)
->getMock();
$mailchimp = new Mailchimp($lists);
// handle job
$job->handle($mailchimp);
// subscriber should be marked subscribed
$this->assertTrue($subscriber->subscribed);
}
Mockery will expect the class being passed in to the controller be a mock object as you can see here in their docs:
class Temperature
{
public function __construct($service)
{
$this->_service = $service;
}
}
Unit Test
$service = m::mock('service');
$service->shouldReceive('readTemp')->times(3)->andReturn(10, 12, 14);
$temperature = new Temperature($service);
In laravel IoC it autoloads the classes and injects them, but since its not autoloading Mailchimp_Lists class it won't be a mock object. Mailchimp is requiring the class atop it's main class require_once 'Mailchimp/Lists.php';
Then Mailchimp is then loading the class automatically in the constructor
$this->lists = new Mailchimp_Lists($this);
I don't think you'll be able to mock that class very easily out of the box. Since there isn't away to pass in the mock object to Mailchimp class and have it replace the instance of the real Mailchimp_Lists
I see you are trying to overwrite the lists member variable with a new Mock before you call the controller. Are you certain that the lists object is being replaced with you mocked one? Try seeing what the classes are in the controller when it gets loaded and see if it is in fact getting overridden.

Yii2 rest save multiple models

Using a REST approach I want to be able to save more than one model in a single action.
class MyController extends ActiveController {
public $modelClass = 'models\MyModel';
}
class MyModel extends ActiveRecord {
...
}
That automagically creates actions for a REST api. The problem is that I want to save more than one model, using only that code in a POST will result in a new record just for MyModel. What if I need to save AnotherModel?
Thanks for any suggestion.
ActiveController implements a common set of basic actions for supporting RESTful access to ActiveRecord. For more advanced use you will need to override them or just merge to them your own custom actions where you will be implementing your own code & logic.
Check in your app the /vendor/yiisoft/yii2/rest/ folder to see how ActiveController is structured and what is doing each of its actions.
Now to start by overriding an ActiveController's action by a custom one, you can do it within your controller. Here is a first example where i'm overriding the createAction:
1-
class MyController extends ActiveController
{
public $modelClass = 'models\MyModel';
public function actions()
{
$actions = parent::actions();
unset($actions['create']);
return $actions;
}
public function actionCreate(){
// your code
}
}
2-
Or you can follow the ActiveController's structure which you can see in /vendor/yiisoft/yii2/rest/ActiveController.php by placing your custom actions in separate files. Here is an example where I'm overriding the updateAction by a custom one where i'm initializing its parameters from myController class :
class MyController extends ActiveController
{
public $modelClass = 'models\MyModel';
public function actions() {
$actions = parent::actions();
$custom_actions = [
'update' => [
'class' => 'app\controllers\actions\WhateverAction',
'modelClass' => $this->modelClass,
'checkAccess' => [$this, 'checkAccess'],
'scenario' => $this->updateScenario,
'params' => \Yii::$app->request->bodyParams,
],
];
return array_merge($actions, $custom_actions);
}
}
Now let's say as example that in my new action file app\controllers\actions\WhateverAction.php I'm expecting the Post Request (which i'm storing in $params) to have a subModels attribute storing a list of child models to which I'm going to apply some extra code like relating them with their parent model if they already exists in first place :
namespace app\controllers\actions;
use Yii;
use yii\base\Model;
use yii\db\ActiveRecord;
use yii\web\ServerErrorHttpException;
use yii\rest\Action;
use app\models\YourSubModel;
class WhateverAction extends Action
{
public $scenario = Model::SCENARIO_DEFAULT;
public $params;
public function run($id)
{
$model = $this->findModel($id);
if ($this->checkAccess) {
call_user_func($this->checkAccess, $this->id, $model);
}
$model->scenario = $this->scenario;
$model->load($this->params, '');
foreach ($this->params["subModels"] as $subModel) {
/**
* your code related to each of your model's posted child
* for example those lines will relate each child model
* to the parent model by saving that to database as their
* relationship has been defined in their respective models (many_to_many or one_to_many)
*
**/
$subModel = YourSubModel::findOne($subModel['id']);
if (!$subModel) throw new ServerErrorHttpException('Failed to update due to unknown related objects.');
$subModel->link('myParentModelName', $model);
//...
}
// ...
return $model;
}
}
So if I understand you wish to add a new database entry not only for the model you are querying, but for another model.
The best place to do this would be in the AfterSave() or BeforeSave() functions of the first model class. Which one would depend on the data you are saving.

Categories