Micrus Tiny, yet full-stack, PHP framework
How big does a framework need to be to provide you with a quick, easy and comfortable way of creating neatly structured MVC websites? That can easily be extended and configured?
Well, not big at all. Just try Micrus! Its goal is to keep it as simple as possible, while offering all the most important features, as listed here:
- Object oriented
- Very clear MVC structure
- Dependency Injection Container
- Event dispatching
- ORM agnostic, support for Doctrine and RedBean
- REST-ful routing
- CRUD generator
- Template agnostic, support for Twig and plain PHP
- Authentication and authorization
- Easy configuration with YAML
- Console tasks using Symfony Console
- Forms
- Localization
- Logging with Monolog (PSR-3 compliant)
- Caching (PSR-6 compliant)
- Flash messages
Documentation
Full documentation is available at docs.avris.it/micrus
Instalation
Starting a project based on Micrus is simple. Just install Composer and run:
composer create-project avris/micrus-starter my_new_project
Composer will download the project with all of its dependencies. It will ask you for server parameters (like database connection) and will generate a secret encryption key unique for your app.
If there are some fixtures available in the project, you can load them with:
bin/micrus db:fixtures
And that's it. If your server is configured correctly, your app should be available under the URL:
http://localhost/my_new_project/web/app_dev.php
You might also want to check out the demo project:
composer create-project avris/micrus-demo
Copyright
- Author: Andrzej Prusinowski (Avris.it)
- Licence: MIT
Disclaimer
I believe that an example is worth a thousand words. That's why Micrus Demo Project is the main source of the documentation for the framework, while the following document might be sketchy and incomplete. Furthermore, Micrus is simple enough, so that investigating its source code might sometimes be easier and quicker way of solving problems than reading the docs.
Getting started
MVC
Micrus is a full-stack MVC framework. What does that mean?
Framework is a special kind of library, that's supposed to organize your code, enforce the project's workflow and integrate it with other libraries.
One of the most popular ways of organizing the code of web applications is the MVC architectural pattern, which stands for Model-View-Controller. It means that three main "layers" of the project are:
- Model - an abstraction layer for the objects that you operate on, like "Article", "Comment", "User" or "Message". Model layer takes care of managing their content and persisting it in the database.
- Controller - handles the user's request, does proper operations on the model and returns the result to the View.
- View - this layer receives data from the Controller and takes care of rendering it and displaying the response for the user.
There are microframeworks and full-stack frameworks. The first one is just a loose frame that doesn't include a lot of features and leaves the developer with big possibilities of adjustment. The latter is fully equipped with features and has a concrete structure. Micrus tries to be small enough, while being fully stacked and extendable.
HTTP and application flow
When opening a website in the browser, your computer communicates with the server via the HTTP protocole. It's fairly simple, text-based and stateless. The browser sends a Request, which is just a text document containing set of key-value headers, and a content.
Let's say it's a request with POST
method to http://myawesomepage.com/post/14/edit
with content that is an array of values such as title, text, publication date etc.
Micrus wraps this request in an abstraction layer, allowing you to easily access all the received data. It has a routing table that binds specific URLs to specific controllers.
It says that the URL POST /post/14/edit
should be handled by the action editAction
inside the controller PostController
. But before executing the controller, Micrus checks,
if the user has the credentials to edit posts. It retrieves a session key from headers and either authorises the user to edit the post, or redirects to the login page.
The controller receives a Request object and has to return a Response object. It's gonna contain the HTML code with, among others, a message that the post has been successfully updated (or not).
It might also set some values in the headers or modify the default 200
response code (like the famous 404
if the post with id=14 doesn't exist in the database – but that case would already be handled by Micrus).
So, to sum up: the Controller receives a Request, then does some operations on the Model(s) (maybe using some external services), asks the View to render an expected HTML code,
and then returns a Response with that HTML.
Directory structure
- app
- Config
- config.yml
- config_dev.yml
- modules.yml
- parameters.yml
- Controller
- Form
- Locale
- Model
- Service
- Task
- View
- layout.html.twig
- bin
- micrus
- run
- cache
- logs
- tests
- vendor
- web
- app.php
- app_dev.php
The web directory should be the only one that's published by the web server. It contains all the frontend data, such as css files, scripts and images, as well as the front controllers. Front controllers are tiny php files – "gateways" to all the rest of the backend code, which is hidden in different directories, so in case of server misconfiguration it doesn't leak. By default there are two front controllers in the project:
-
app_dev.php runs your app in the "dev" environment (i.e. your local computer, where you develop it), sample page can be accessed in this env via:
http://localhost/Micrus/web/app_dev.php/hello
-
app.php runs your app in the "prod" environment (i.e. the server where it is supposed to be actually used by users), it is the default front controller, so it can be accessed simply via:
http://mywebpage.com/hello
The vendor directory contains all the 3-rd party code: Micrus itself and other libraries.
The run directory should contain all the files that get created during runtime: cache, logs, user uploads etc. Make sure that is writable by the server!
The tests directory should contain all the tests for your project, and preserve the namespace structure of the app dir.
Directory bin will contain the console executables, like PHPMD, PHPUnit, and most notably: bin/micrus
that can execute all the Micrus-related tasks, like clearing cache of loading fixtures. This also includes your own custom commands.
app is the directory where all the backend code for your application is located. It is organised according to the MVC pattern, which keeps different layers of an application nicely separated:
- Model is an entity that contains data and is being used by the app, e.g.: post, comment, user, token, etc. It may or may not be stored in the database.
-
View contains the templates (
.phtml
,.twig
or else, depending on which template engine you're using). - Controller contains the classes that do the actual business logic of the application. They handle user's request, manipulate the data, and use View to display it.
-
Form is an abstraction layer between model and it's representation in html
<form...>
-related tags (requires Forms Module). - Service namespace contains all the classes that are supposed to be shared between the application modules (controllers, views, other services), that do a specific job (like handling events or sending emails), and that generally (but necessarily) have at most one instance in the system.
-
Task directory is a place for the classes with code to be executed without grahical interface, using the
bin/micrus
file. -
Config, obviously, contains all the config files for your project. They use YAML format. Only one of them,
config.yml
, is obligatory, but it might be split into more (parameters.yml
,routing.yml
) for readability, and customized for different environments (config_dev.yml
,config_test.yml
).
Routing and controllers
Routing is a table of rules that determine which URL should be handled by which controller. It is placed in app/config/routing.yml and looks like this:
home:
pattern: /
target: Home/home
helloUser:
pattern: /hello/{id}
target: Home/helloUser
requirements: { id: \d+ }
helloName:
pattern: /hello/{name}
target: Home/helloName
defaults: { name: Stranger }
methods: GET
Or with a shorthand notation:
home: / -> Home/home
helloUser: /hello/{int:id} -> Home/helloUser
helloName: GET /hello/{name=Stranger} -> Home/helloName
There should always be a route called home
(it's used for instance as a fallback redirect after logging out
or switching locale of the page). For every route, fields pattern
and target
are required.
In our example, when URL /
is requested, router gives control to the controller Home/home
(that means: class App\Controller\HomeController
, method homeAction
).
The target might also be a service from the Container: @serviceName/method
.
If the requested URL doesn't match /
, the next route is checked.
This one catches everything that starts with /hello/
and then follows with one or mode decimals (because of the requirements
field).
URLs /hello/5
and /hello/123
do match, but /hello/foo
, /hello/123/456
or /hello
don't.
The next route catches everything like /hello/Thomas
, /hello/Josh
or /hello/Meteo
or even /hello
alone
– it this case parameter name
is set to default value Stranger
.
Example flows:
- for URL
/
first route is matched, - for URL
/hello/25
first route is not matched, but second one is, so the router sends to the controller ID of a user to welcome, - for URL
/hello/Kate
first route is matched, second one would be, if it wasn't for requirements, but the third one is – so controller recieves the name to greet the user with, - for URL
/whatever
first rule is checked, then the second, then the third, but none is matched, so NotFoundException is thrown.
The simplest controller to handle the request matching our helloName
route looks like this:
public function helloNameAction($name)
{
return $this->render(['name' => $name]);
}
It receives $name
from the URL and simply sends it to the view to be rendered.
All the parameters that router has found in a route can be automatically tranformed into model. You just have to specify the class of a parameter in the action's header, and it will get fetched from the database:
public function helloUserAction(User $user)
{
return $this->render(['name' => $user->getUsername()]);
}
If method
is set (to GET, POST, PUT or DELETE), router will only accept that given HTTP method for this route.
Every controller must return an object implementing Avris\Micrus\Controller\Http\ResponseInterface
. For instance for JSON it would be:
return new Response(json_encode($vars), 200, ['content-type' => 'application/json']);
where 200
is a response code, and content-type
is a header. You might as well use this shortcut:
return $this->renderJson($vars);
There is a shortcurt for a "regular" HTML response as well, of course:
return $this->render(['variable' => $value]);
This array might contain a key _view
with a identifier (filename) of the template that should be rendered.
If it's not set, it defauls to {controller name}/{action name}
.
You don't have to specify the extension of a template, if it's the default extension of your template engine
(.html.twig
for Twig, .phtml
for plain PHP, etc.)
So for instance our helloUserAction
would render the file Hello/helloUser.html.twig
, if no other is specified.
Third kind of most "popular" responses is the redirect.
return $this->redirect($url);
The $url
might be just a plain URL, or you can generate it using the router (yup, the router works both ways).
return $this->redirect($this->generateUrl('helloName', ['name' => 'Jessica']);
Or with a shortcut:
return $this->redirectToRoute('helloName', ['name' => 'Jessica']);
From the controller you can access main components of the app, via:
get
(service), trigger
(event), getRouter
, getUser
, getRootDir
, getEm
(entity manager of your ORM), getParameter
, isGranted
,
getRequest
, getQuery
, getData
, getSession
, getFiles
, getCookies
, setCookie
.
They all do what they promise to. Please note, that the superglobal variables (well, globals too) shouldn't be ever used in the project.
They are all accessible in the wrappers.
DO: $this->getData()->get('title')
or shorter $this->getData('title')
, DON'T: $_POST['title']
.
Btw, with the wrappers you don't need to check for isset
– if the key is not in the array, null
will be returned (see: Avris Bag).
You can also create a flash message using $this->addFlash($type, $message)
.
They are tiny messages for the user, such as Object created successfuly!
, that are set before a redirect,
live for just one more request, are displayed once and then forgotten.
By default they are deferred for the next request (set flash, redirect somewhere, show flash there),
but adding false
as the last parameter makes them shown right away.
In the view they can be rendered like this:
{% for flash in app.flashBag.all %}
<div class="alert alert-{{ flash.type }}">
<p>{{ flash.message }}</p>
</div>
{% endfor %}
Model
Micrus is ORM agnostic, so you can use whatever ORM you want (we support Doctrine and RedBean). For all the ORM-specific stuff, check out their documentation.
All the Model classes should to be put in the App\Model
namespace.
Automatcher
The automatcher
service translates string values from the URL
("tags", like value of id
in the route /posts/{id}/edit
)
into other values, depending on what a specific callable expects.
Automatcher is used to pass more meaningful data to controllers and to security check methods.
With its help our controllers don't have to look like that:
public function editAction($postId)
{
$request = $this->get('request');
$post = $this->getEm()->getRepository(Post::class)->find($postId);
if (!$post) {
throw new NotFoundException;
}
// ...
}
instead, they can look like that:
public function editAction(Request $request, Post $post)
{
// ...
}
Automatcher first fills up all the parameters from the beginning that implement any interface from its internal list.
By default it contains ContainerInterface
and RequestInterface
, but you can extend this list by defining a service
that has a tag automatcherInterfacesMap
and implements Avris\Micrus\Model\AutomatcherInterfacesMap
.
For the rest of parameters, automatcher works like this: if a scalar is expected, value is just passed on,
and if it's an object, automatcher fetches it from ORM (and throws NotFoundException
if it's not there).
You can overwrite that behaviour for specific tags by defining a service that has a tag automatcherSpecialTags
and implements Avris\Micrus\Model\AutomatcherSpecialTags
. Tags defined that way might either be ignored
(null
) or applied a custom callback.
Fixtures
Fixtures are sets of data that are supposed to be loaded in dev and test environments, so that the developers and functional test have some mocked entities to work on.
Micrus provides a simple way to load them. Just create a console task extending AbstractFixturesTask
,
and inside its load
method add some code that truncates the database and creates entities.
You can then execute that task with:
php bin/micrus db:fixtures
A simple example for the Doctrine ORM:
<?php
namespace App\Task;
use App\Model\Post;
use App\Model\Category;
use Avris\Micrus\Doctrine\DoctrineFixturesTask; // extends Avris\Micrus\Console\Task\AbstractFixturesTask;
use Symfony\Component\Console\Output\OutputInterface;
class FixturesTask extends DoctrineFixturesTask
{
protected function load(OutputInterface $output)
{
$output->writeln('Truncating the database');
$this->truncateDatabase();
$crypt = $this->container->get('crypt');
$output->writeln('Loading: users');
$admin = new User();
$admin->setUsername('admin');
$admin->setEmail('admin@micrus.avris.it');
$admin->setRole('ROLE_ADMIN');
$admin->createAuthenticator(SecurityManager::AUTHENTICATOR_PASSWORD, $crypt->hash('admin'));
$this->em->persist($admin);
$user = new User();
$user->setUsername('user');
$user->setEmail('user@micrus.avris.it');
$user->setRole('ROLE_USER');
$user->createAuthenticator(SecurityManager::AUTHENTICATOR_PASSWORD, $crypt->hash('user'));
$this->em->persist($user);
$output->writeln('Loading: categories');
$categories = array();
foreach (['comedy','drama','tragedy','news','info','curiosity'] as $name) {
$category = new Category();
$category->setName($name);
$this->em->persist($category);
$categories[] = $category;
}
$this->em->flush();
$output->writeln('Fixtures loaded');
}
}
View
Micrus is template engine agnostic (we support plain PHP and Twig). Whichever one you're using, those Micrus-specific variables, functions and filters will be available:
- variable app with such properties:
- user – currently logged in,
- flashBag – see Routing and controllers,
- request,
- locales – all allowed locales (only with Localizator installed),
- locale – currently set locale (only with Localizator installed),
- function route(name, params = []) which generates an URL for the given route name and the (optional) array of parameters,
- function routeExists(name),
- function asset(filename) which generates an filename for an asset that will work regardless of current URL or app's position in the filesystem, for instance:
<script src="{{ asset('asset/main.js') }}"></script>
, - function isGranted(role),
- filter l that translates to the current locale the expression that it's applied to (only with Localizator installed).
Forms
Without a framework, when you want to create a form, there are many things you must take into consideration: binding an existing object (if any) to each sparate field of the form, validating them after user has submited the form, if invalid redisplaying it if with POST data bound and with validation errors...
Micrus adds an abstraction layer that handles all of that. You just need to define the list of fields you need and their cofiguration options. You'll get an object that will handle everything for you. Just use it in the controller and display it in the view.
Let's look at an example (using Doctrine and Twig):
<?php
namespace App\Form;
use App\Model\User;
use Avris\Micrus\Model\Assert as Assert;
use Avris\Micrus\Model\Form;
use Avris\Micrus\Tool\Crypt;
class RegisterForm extends Form
{
public function configure() {
$this
->add('username', 'Text', [
'placeholder' => l('entity.User.custom.usernameRegex'),
], [
new Assert\NotBlank(),
new Assert\Regexp('^[A-Za-z0-9_]+$', l('entity.User.custom.usernameRegex')),
new Assert\MinLength(5),
new Assert\MaxLength(25),
new Assert\Unique(
$this->options->get('orm'), $this->object, 'user', 'username',
l('entity.User.custom.usernameTaken')
),
])
->add('email', 'Email', [], new Assert\NotBlank())
->add('doPasswordsMatch', 'ObjectValidator')
->add('password', 'Password',
[new Assert\NotBlank(), new Assert\MinLength(5)]
)
->add('passwordRepeat', 'Password', [], new Assert\NotBlank)
->add('agree', 'Checkbox', [
'label' => '',
'sublabel' => l('entity.User.custom.agreement')
], new Assert\NotBlank)
;
}
public function doPasswordsMatch($user)
{
return $user->password === $user->passwordRepeat
? true
: l('entity.User.custom.passwordMismatch');
}
}
Handled by the controller like this:
public function registerAction()
{
if ($this->getUser()) {
return $this->redirectToRoute('myAccount');
}
$form = new RegisterForm(new User(), $this->container);
$form->bind($this->getData());
if ($form->isValid()) {
$user = $form->getObject();
$user->setPassword($this->getService('crypt')->hash($user->getPassword()));
$this->getEm()->persist($user);
$this->getEm()->flush();
$this->getService('securityManager')->login($user);
return $this->redirectToRoute('myAccount');
}
return $this->render(['form' => $form->setStyle(new Bootstrap2)]);
}
And Displayed in the view like that:
<form method="post" class="form">
{{ form|raw }}
<div class="col-lg-offset-2">
<button type="submit" class="btn btn-primary">Register</button>
</div>
</form>
The method configure()
is executed right after creating a form. In there you can define all the fields.
Widgets
The add($name, $type = 'Text', $options = [], $asserts = [], $visible = true)
method lets you add a field to your form.
$name
parameter must be unique because it will be the name of object's property.
$type
is a string that defines which widget should be used:
- Text (default)
- TextAddon (bootstrap)
- Number
- Integer
- NumberAddon (bootstrap)
- Url
- Hidden
- Textarea
- Checkbox
- Choice
- ButtonChoice (bootstrap)
- IconChoice (bootstrap)
- Date
- DateTime
- Time
- File
- Password
- Display (allows custom HTML)
Choice
widget has four remarkable options:
-
choices => [key => value, ...]
– a list of choices to be presented to the user, -
model => string
– if specified will generatechoices
as a list of all entities of current type from the database (with theirid
s as keys and__toString()
s as values) and bind it back to an entity, -
multiple => bool
– if user can select one or many options, -
expanded => bool
– if Micrus should generate oneselect
control or manycheckbox
/radio
ones.
There are to special widgets:
- CSRF – a security token added automatically to every form (to disable CSRF protection, add
csrf => false
to the options in form's constructor), - ObjectValidator – not a widget, but a placeholder for a validation that requires checking a database (is username free?)
or checking multiple fields (are passwords equal?). It uses whatever
callback
you specify in its options. This callback should returntrue
if no errors found, and an error message otherwise.
Asserts
Available asserts are:
- NotBlank
- Url
- MaxLength
- MinLength
- Regexp
- Number
- Integer
- Min
- Max
- Step
- Date
- DateTime
- Time
- MinDate
- MaxDate
- ObjectValidator
- CorrectPassword
- Choice
- Csrf
- File\File
- File\Image
- File\Extension
- File\Type
- File\MaxHeight
- File\MinHeight
- File\MaxWidth
- File\MinWidth
- File\MaxSize
- File\MaxRatio
Many widgets automatically add a relevant assert, so you don't have to.
Styles
Use setStyle
to change how the form is rendered to HTML.
For instance with ->setStyle(new Bootstrap2)
you get each widget wrapped into Bootstrap classes with 2 columns for label and 10 for the widget.
Available styles are: Bootstrap
, Bootstrap1
, Bootstrap2
, Bootstrap3
, BootstrapHalf
and BootstrapMini
.
You can create your own by extending class Avris\Micrus\View\FormStyle\Formstyle
taking the existing ones as an example.
Iterating
It's sometimes useful not to display the whole form at once, but with some chunks. For instance:
<form method="post" class="form row">
<div class="col-lg-4">
{% for widget in form.iterate(null, 'widget3') %}
{{ widget|raw }}
{% endfor %}
</div>
<div class="col-lg-4">
{% for widget in form.iterate('widget4', 'widget6') %}
{{ widget|raw }}
{% endfor %}
</div>
<div class="col-lg-4">
{% for widget in form.iterate('widget7') %}
{{ widget|raw }}
{% endfor %}
</div>
</form>
The ->iterate($start, $stop)
function comes in handy here.
If you omit the $start
argument, it will start iterating from the beginning,
and if you omit $stop
, it will go to the very last widget.
Services
Dependency Injection
Services are classes that are supposed to be shared throughout the application, that do a specific job (like handling event or sending emails) and that generally (but necessarily) have at most one instance in the system.
Micrus uses Avris Container.
Please check out its documentation to learn about how it works.
All that Micrus itself adds to this Container implementation
is the ability to configure it using .yml
files like this:
App\:
dir: '%MODULE_DIR%/src/'
exclude:
- '#^Entity/#'
App\Service\Mailer:
params: [@config.parameters.?mailer]
calls:
- [setLogger, [@?Psr\Log\LoggerInterface]]
tags: [defaultParameters]
App\Service\ResponseListener:
arguments:
$logger: @Psr\Log\LoggerInterface
events: [response]
App\Service\WaveTwigExtension:
tags: [twigExtension]
countedProduct:
class: App\Service\CountedProduct
countedProductFactory:
class: App\Service\CountedProduct
factory: true
Console tasks
All the actions that don't require GUI, like administrative stuff (load fixtures, manual password generation)
or scheduled jobs (using cron) can be run from the console on a server. Just cd
to the project directory and run php bin/micrus
.
You will see a list of available commands to run.
Tasks in Micrus are just an extension of a great tool – Symfony Console Component. Head to its documentation for more information.
As for the Micrus specific stuff: for your custom tasks to be visible in bin/micrus
, they have to reside in App\Task
namespace,
end with *Task
and extend Avris\Micrus\Console\Task
. Inside them you will have access to $this->container
, $this->env
,
$this->em
and $this->rootDir
.
Event Dispatcher
Micrus uses Avris Dispatcher. Please check out its documentation to learn about how it works.
Micrus provides and triggers some events that you can listen to and intercept the execution of your app:
-
warmup
– before handling an event -
request
– before a request gets handled at all -
controller
– right before a controller gets executed (can for instance switch to a different one) -
view
– right after a controller gets executed (can for instance convert its return value to aResponseInterface
instance) -
response
– right before a response is sent (can for instance add some additional headers to all responses) -
terminate
– after sending a response (can for instance send an email in the background) -
error
– when an exception is thrown or an error triggered (can for instance generate an error page or send a notification email)
Also for the CLI commands
-
consoleWarmup
– before running a command -
cacheClear
– whencache:clear
is run -
cacheWarmup
– whencache:warmup
is run
To create your own event, just extend Avris\Micrus\Tool\Bootstrap\Event
.
Console tasks
All the actions that don't require GUI, like administrative stuff (load fixtures, manual password generation)
or scheduled jobs (using cron) can be run from the console on a server.
Just cd
to the project directory and run bin/micrus
(or php bin/micrus
on Windows).
You will see a list of available commands to run.
Micrus uses a great tool for that – Symfony Console Component. Head to its documentation for more information.
To register a command just create a service extending Command
and tagged command
(will be tagged automatically).
Localization
Locale is a set of data that lets you deliver your website adjusted to a given localization. It includes mainly the translations, but also currency, data format and so on.
Locale's identifier is: the ISO 639-1 language code, then an underscore (_), then the ISO 3166-1 alpha-2 country code. Or is might be the language code alone.
Locales are placed in app/Locale
directory in form of YAML files, for instance:
- app
- Locale
- en.yml
- en_UK.yml
- en_UK.yml
- pl.yml
en.yml
would contain words and phrases shared by both British and American English, while en_UK
could look like this:
color: Colour
dateFormat: d/m/Y
currency:
before: £
and en_US
like this:
color: Color
dateFormat: m/d/Y
currency:
before: $
pl
should of course all the data in one file, since it has no "children".
In the app/Config/config.yml
file you should to set the list of allowed locales and a fallback:
locales:
en_GB: English (GB)
en_US: English (USA)
pl: Polski
de: Deutsch
fallbackLocale: en_GB
Now, when a word is to be translated by Micrus' Localizator, and current user's locale is set to en_UK
,
this is what happens:
- Word is looked up in
en_UK.yml
and returned if found, - Otherwise it's looked up in "parent" locale,
en.yml
, and returned if found, - Otherwise – in the fallback,
pl.yml
, and returned if found, - If translation still not found, the original word is returned untranslated.
If user's current locale were pl
, then only pl.yml
would be checked.
If no locale is set in the session, Micrus will try to guess the best one, based on browser's headers and the list of available locales.
Usage
Localization is a thing that's really widely used. Virtually every part of the application might want to
translate some strings. And rightfully injecting the Localizator service everywhere could be incredibly annoying...
That's why localization, exceptionally, has it's global function, l(word, replacements = [])
. It creates
a LocalizedString object, which gets translated when casted to simple string.
$this->addFlash('success', l('entity.Post.actions.createSuccess', ['%title%' => $post->getTitle()]));
If your locale contains this entry:
entity:
Post:
actions:
createSuccess: Post "%title%" has been successfully created!
And your post has a title "Lorem ipsum", then the displayed flash message will say:
Post "Lorem ipsum" has been successfully created!
The localizator can also be used directly:
$this->getService('localizator')->get('entity.Post.actions.createSuccess', ['%name%' => $post->getName()]);
And in the view (in case of Twig):
{{ 'entity.Post.actions.createSuccess'|l({'%title%': post.title}) }}
To set a locale:
$request->getSession()->set('_locale', $locale);
where $locale
is simply a string identifying locale. Or you can simply generate a route to do it for you:
<a href="{{ route('locale_change', { locale: 'en_UK' }) }}">
Locale sets, conventions
Your app/Locale
directory doesn't have to be the only one. External libraries might offer their own.
As a matter of fact, Micrus provides one itself (for the validators). Keeping the locales organized in tree structures
helps avoiding conflicts between them. Entries in app/Locale
will always overwrite any other locale set.
In order for the translations to be reused amid modules, please stick to the convention regarding all the model-related translations, as shown in the example:
entity:
User:
singular: User
plural: Users
fields:
username: Login
password: Password
passwordRepeat: Repeat password
email: Email
posts: Posts
postsCount: Posts count
role: Role
roleName: Role
rememberMe: Remember me
actions:
testEmail:
name: Test email
subject: Test email
content: Test email sent from demo application of the <a href="https://micrus.avris.it">Micrus</a> framework
success: Email sent
failure: Error while sending an email
register: Register
login: Log in
logout: Log out
myAccount: My account
custom:
agreement: I agree to terms and conditions of...
wrongCredentials: Incorrect credentials
usernameTaken: Selected login is already taken
usernameRegex: Login can only contain letters and numbers
passwordMismatch: Given passwords don't match
Dependency Injection of Services
Services are classes that are supposed to be shared between the application modules (controllers, views, other services, etc.), that do a specific job (like handling event or sending emails), and that generally (but necessarily) have at most one instance in the system.
They depend on one another. For instance, securityManager
, responsible i.a. for logging users in and out, requires
crypt
that hashes passwords for it. Of course, securityManager
and every other service that uses crypt
, might all
just create their own instances of Crypt. But what if you wanted to change the hashing algorithm? You cannot just edit
the code of Avris\Micrus\Tool\Crypt
(unless you want to repeat that for every instance of your application after every
update of your vendors), and it's not a good idea either to change each and every of those services to start using
your new implementation of the crypting tool.
Dependency Injection is a great idea to solve such problems. With DI, there is just one element, called Container, that
manages all the services and dependencies between them, while the services only receive them. In that case, you'd just
have to tell the Container, that from now on crypt
should mean an instance of your implementation, instead of the default
Avris\Micrus\Tool\Crypt
.
To register a service (or overwrite one declared by Micrus or other library), you must add it to app/Config/services.yml
and create a class for it (most often in App\Service
namespace).
mailer:
class: App\Service\Mailer
parameters: [@config.parameters.mailer, @?logger]
tags: [defaultParameters]
responseListener:
class: App\Service\ResponseListener
parameters: [@logger]
events: [response]
waveTwigExtension:
class: App\Service\WaveTwigExtension
tags: [twigExtension]
countedProduct:
class: App\Service\CountedProduct
countedProductFactory:
class: App\Service\CountedProduct
factory: true
assertLocaleSet: ~
We declared five services here. Only class
field is required. A parameter can be:
- a string,
- a parameter from
app/Config/parameters.yml
– it should look like this:%name%
, - another service – it should look like that:
@name
- a set of tagged services – it should look like that: #tagName
Service assertLocaleSet
is set by default by the framework, but you can unset it with ~
.
You can also interpolate stuff using the syntax: {@rootDir}/run/images
. If a service is not required, you can specify that
using a question mark: @?name
.
The parameters are injected to the service by the constructor:
<?php
namespace App\Service;
use App\Test\MockPHPMailer;
use Avris\Micrus\ParameterBag;
use Avris\Micrus\Tool\ParametersProvider;
use Avris\Micrus\Tool\Logger;
class Mailer implements ParametersProvider
{
/** @var array */
protected $options;
/** @var Logger */
protected $logger;
public function __construct($options, Logger $logger = null)
{
$this->options = $options;
$this->logger = $logger;
}
public function send($subject, $content, array $receivers)
{
// do the sending
if ($this->logger) {
$this->logger->info(l('mailer.sent', [%count% => count($reveivers)]);
}
}
}
To send an email:
/** @var Mailer $mailer */
$mailer = $this->getService('mailer');
$mailer->send($subject, $content, ['test@example.com' => 'Tester'])
Direct access to the Container
Container itself is a service as well, so it can be accessed with $this->getService('container')
(in the controller) or injected as @container
.
You can manually register and retrieve services, using its set
, setArray
, has
, get
and remove
methods.
Note that the service can be anything but an array! If you set an array, it will be understood as a service configuration yet to be resolved.
Events
Services can listen to the events, such as request
, response
, or clearCache
. Just before handling the request,
Micrus notifies the Container to trigger request
event. Container executes then the requestEvent
method
of every service that is registered with this event. The same happens for all the other event types at different times
of application lifecycle.
Let's take a look at an example of service that listens to request
event:
<?php
namespace App\Service;
use Avris\Micrus\Controller\Http\ResponseEvent;
use Avris\Micrus\Tool\Logger;
class ResponseListener
{
/** @var Logger $logger */
protected $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function responseEvent(ResponseEvent $event)
{
$this->logger->log('info', 'Response length: ' . strlen($event->getResponse()->getContent()));
}
}
To create your own event, just extend Avris\Micrus\Event
:
class MyOwnEvent extends Event
{
private $param;
public function __construct($param)
{
$this->param = $param;
}
public function getName()
{
return 'myown';
}
public function getParam()
{
return $this->param;
}
}
And to trigger it:
$this->getService('container')->triggerEvent(new MyOwnEvent('foobar'));
Now all the services that listen to myown
will have their myownEvent
method executed.
Note that if you need a service to be run as the first one in chain when triggering an event, put an !
before the event name, eg.:
foo:
class: App\Service\Foo
events: ['!request', 'someOtherEvent']
Tags
Services can be grouped by tags. Other ones might then retrieve all of them at once and use them as they want.
For instance, if you're using Twig, then every time it initializes, it will get injected with #twigExtension
and register all of the classes it gets as Twig extensions.
Factory vs. singleton
By default services are created as singletons, which means there's always just once instance of them (well, at most one, since a service isn't initialized unless it's needed). Why waste memory on dozens of instances of a mailer, if they are going to be exactly the same? Plus, in most cases you actually need the same instance of a service used by different services.
But sometimes it's the opposite: you may need a new instance of some class every time you inject it somewhere.
For this feature, just specify factory: true
in the service declaration.
Resolvers
Let's say your website consists of multiple games, each on a different domain, but running by the same code.
You want to have a game
service that stores the currently open game.
It's a more complicated logic than just putting plain data to DI or creating a class by classname and parameters.
In this case, declare a resolver service:
game:
class: App\Service\GameResolver
parameters: ['@request', '@orm.entityManager']
And make it implement Avris\Micrus\Tool\Resolver
interface.
Whatever you return in its resolve
method, will be put to DI as game
service.
<?php
namespace App\Service;
use Avris\Micrus\Tool\Resolver;
use Avris\Micrus\Controller\Http\Request;
use Doctrine\ORM\EntityManager;
class GameResolver implements Resolver
{
protected $request;
protected $em;
public function __construct(Request $request, EntityManager $em)
{
$this->request = $request;
$this->em = $em;
}
public function resolve()
{
return $this->em->getRepository('game')->findOneByDomain($request->getServer('SERVER_NAME'));
}
}
Security
Micrus handles both user's authentication (who are you?) and authorisation (do you have access to this page?).
Three most important security-related services are UserProvider
, Crypt
and SecurityManager
.
UserProvider
finds a user by an identifier (email, username, ...) in any storage (config file, database, ...).
Crypt
takes care of hashing & validating passwords and encrypting & decrypting data.
SecurityManager
makes sure that only authorised : of logging the user in or out, checking their credentials etc.
In Micrus's approach, the user doesn't just have one password, but might have multiple "Authenticators". Those could be passwords, as well as "remember me" tokens stored in cookies or access tokens from OAuth, Facebook, Google+ etc.
You should create class App\Model\User
that implements interface Avris\Micrus\Model\User\UserInterface
and App\Model\Authenticator
that implements interface Avris\Micrus\Model\User\AuthenticatorInterface
.
Authorisation restrictions
Sample config/security.yml
:
security:
loginPath: userLogin # default: login
afterLogout: userLogout # default: home
rememberMeForDays: 7 # default: 30
restrictions:
- { pattern: ^/admin, roles: [ROLE_ADMIN] }
- { pattern: ^/post/add$ } # login required, but not any particular role
- { pattern: ^/post/(\d+)/edit$, check: edit, object: '$post' }
- { controller: App\Controller\Secret, roles: [ROLE_SECRET_KNOWER] }
- { controller: App\Controller\Foobar/secret, roles: [ROLE_SECRET_KNOWER] }
Security restrictions can also be defined using Annotations.
Before every request, the SecurityManager
checks if the request matches any of the restrictions
.
If it doesn't, the app flow simply continues to the controller. But if a restriction is met:
- if user is not logged in,
SecurityManager
redirects them to the path with name specified inloginPath
, - if user is logged in, then they must meet some condition (
roles
,check
, or just the fact of being logged in) – if they don't, aForbiddenHttpException
is thrown.
The roles
condition checks if $user->getRoles()
has at least one of those specified by roles
.
The check
condition checks for more complex conditions, like "post can only be edited by its author".
To create such a check, implement GuardInterface
. Your Guard will be invoked with:
- the name of the action that is to be performed (
edit
,delete
etc.), - the object that the action is to be performed on (in this case the
$post
argument that would be passed to the controller), - the
User
object (or null) and will be able to say if it wants to make a decision about that restriction at all, and whether the action is allowed or not.
Public paths
To declare some paths as public, regardless of other restrictions, just use public option.
For instance of you want all paths except /login
to require being logged in, use such configuration:
security:
loginPath: login
afterLogout: login
public:
- { pattern: ^/login }
restrictions:
- { pattern: ^/ }
Role hierarchy
If you want all managers to automatically have all the permissions of normal users, and all admins to automatically have all the permissions of normal users and of managers, then configure those roles like this:
security:
roles:
ROLE_ADMIN: [ROLE_USER, ROLE_MANAGER]
ROLE_MANAGER: [ROLE_USER]
Security enhancements
security:
preventSessionFixation: true
cookiesOnlyHttp: true
encryptCookies: true
ssl: true
secureHeaders:
enabled: true
# check Avris\Micrus\Tool\Security\SecurityEnhancer::$headers for the list of those headers
UserProviders
Service UserProviderInterface
is used to retrieve user data from whatever source.
By default it's an instance of Avris\Micrus\Model\MemoryUserProvider
, which is read-only
and takes the list of users from @config.security.users
.
Such list should be defined in the following format:
security:
users:
admin:
roles: [ROLE_ADMIN]
authenticators:
password: 7282d579f3c1651f44065bdde0a801d0f05e110d2e4d835274e5429d96b75679bfb815021dd8b0e4
user:
roles: [ROLE_USER]
authenticators:
password: 7a2e81a01de38cfc51d6cf5afea59de2a11d031769974ef33536c21161ad66191fcd1bfec436326c
Note that you can generate password hashes using php bin/micrus security:password:hash
.
If you have an ORM module installed, it should automatically overwrite UserProviderInterface
to it's own,
which will retrieve user entity straight from the database.
Services
Dependency Injection
Services are classes that are supposed to be shared between the application modules (controllers, views, other services, etc.), that do a specific job (like handling event or sending emails), and that generally (but necessarily) have at most one instance in the system.
They depend on one another. For instance, securityManager
, responsible i.a. for logging users in and out, requires
crypt
that hashes passwords for it. Of course, securityManager
and every other service that uses crypt
, might all
just create their own instances of Crypt. But what if you wanted to change the hashing algorithm? You cannot just edit
the code of Avris\Micrus\Tool\Security\Crypt
(unless you want to repeat that for every instance of your application after every
update of your vendors), and on the other hand it's not a good idea either to change each and every of those services to start using
your new implementation of the encrypting tool.
Dependency Injection is a great idea to solve such problems. With DI, there is just one element, called Container, that
manages all the services and dependencies between them, while the services only receive them. In that case, you'd just
have to tell the Container, that from now on crypt
should mean an instance of your implementation, instead of the default
Avris\Micrus\Tool\Security\Crypt
. Container will automatically inject it to all the servers that need it.
To register a service (or overwrite one declared by Micrus or other library), you must add it to app/Config/services.yml
and create a class for it (most often in App\Service
namespace).
mailer:
class: App\Service\Mailer
params: [@config.parameters.?mailer]
calls:
setLogger: [@?logger]
tags: [defaultParameters]
responseListener:
class: App\Service\ResponseListener
params: [@logger]
events: [response]
waveTwigExtension:
class: App\Service\WaveTwigExtension
tags: [twigExtension]
countedProduct:
class: App\Service\CountedProduct
countedProductFactory:
class: App\Service\CountedProduct
factory: true
We declared five services here. Only class
field is required. A param can be:
- a string,
- a parameter from
app/Config/parameters.yml
– it should look like this:%name%
, - another service – it should look like that:
@name
- a set of tagged services – it should look like that:
#tagName
Those params are injected to the service by its constructor (and possibly also method calls), for example:
<?php
namespace App\Service;
use App\Test\MockPHPMailer;
use Avris\Bag\Bag;
use Avris\Micrus\Tool\Config\ParametersProvider;
use Avris\Micrus\Tool\Logger;
class Mailer implements ParametersProvider
{
/** @var Bag */
protected $options;
/** @var Logger */
protected $logger;
public function __construct(Bag $options)
{
$this->options = $options;
$this->logger = $logger;
}
public function setLogger(Logger $logger = null)
{
$this->logger = $logger;
return $this;
}
public function send($subject, $content, array $receivers)
{
// do the sending...
if ($this->logger) {
$this->logger->info(count($reveivers) . ' emails sent');
}
}
}
Now to send an email from a controller, do this:
/** @var Mailer $mailer */
$mailer = $this->getService('mailer');
$mailer->send($subject, $content, ['test@example.com' => 'Tester'])
If a service is not required, you can specify that using a question mark: @?name
.
You can also interpolate stuff into params using the syntax: {@rootDir}/run/images
.
To put in the container a string value depending on other services, use resolve
option:
picDir:
resolve: '{@rootDir}/run/pic'
If you want to remove a service that has been set by default by the framework, or by a module, set it to null:
serviceName: ~
Direct access to the Container
Container itself is a service as well, so it can be injected as @container
(or just @
).
In a controller you can also accessed it with $this->controller
.
You can manually register and retrieve services, using its set
, has
, get
and remove
methods.
Note that the service can be anything but an array! If you set an array, it will be understood as a service configuration yet to be resolved.
Resolvers
Let's say your website consists of multiple games, each on a different domain, but running by the same code.
You want to have a game
service that stores the currently open game.
It's a more complicated logic than just putting plain data to the Container or creating a class by classname and parameters.
In this case, declare a resolver service:
game:
class: App\Service\GameResolver
params: ['@request', '@orm.entityManager']
And make it implement Avris\Micrus\Tool\Bootstrap\Resolver
interface.
Whatever you return in its resolve
method, will be put to DI as game
service.
<?php
namespace App\Service;
use Avris\Micrus\Tool\Bootstrap\Resolver;
use Avris\Micrus\Controller\Http\Request;
use Doctrine\ORM\EntityManager;
class GameResolver implements Resolver
{
protected $request;
protected $em;
public function __construct(Request $request, EntityManager $em)
{
$this->request = $request;
$this->em = $em;
}
public function resolve()
{
return $this->em->getRepository('game')->findOneByDomain($request->getServer('SERVER_NAME'));
}
}
Tags
Services can be grouped by tags. Other ones might then retrieve all of them at once and use them as they want.
For instance, if you're using Twig, then every time it initializes, it will get injected with #twigExtension
and register all of the classes it gets as Twig extensions.
Factory vs. singleton
By default services are created as singletons, which means there's always just once instance of them (well, at most one, since a service isn't initialized unless it's needed). Why waste memory on dozens of instances of a mailer, if they are going to be exactly the same? Plus, in most cases you actually need the same instance of a service used by different services.
But sometimes it's the opposite: you may need a new instance of some class every time you inject it somewhere.
For this feature, just specify factory: true
in the service declaration.
Event Dispatcher
Services can listen to the events, such as request
, response
, or clearCache
. Just before handling the request,
Micrus notifies the Event Dispatcher to trigger request
event. Dispatcher executes then the onRequest
method
of every service that is registered with this event. The same happens for all the other event types at different times
of application lifecycle.
You can declare event listeners either in the config:
serviceName:
class: Namespace\Class
events: [eventName, anotherEvent:7]
or attach them yourself:
$this->getService('dispatcher')->attach('eventName', 'serviceName', 7);
The 7
in those examples is the priority of a given listener (default 0
).
Let's take a look at an example of service that listens to response
event:
<?php
namespace App\Service;
use Avris\Micrus\Controller\Http\ResponseEvent;
use Avris\Micrus\Tool\Logger;
class ResponseListener
{
/** @var Logger $logger */
protected $logger;
public function __construct(Logger $logger)
{
$this->logger = $logger;
}
public function onResponse(ResponseEvent $event)
{
$this->logger->log('info', 'Response length: ' . strlen($event->getResponse()->getContent()));
}
}
To create your own event, just extend Avris\Micrus\Tool\Bootstrap\Event
:
class MyOwnEvent extends Event
{
private $param;
public function __construct($param)
{
$this->param = $param;
}
public function getName()
{
return 'myown';
}
public function getParam()
{
return $this->param;
}
}
And to trigger it:
$this->getService('dispatcher')->trigger(new MyOwnEvent('foobar'));
Now all the services that listen to myown
will have their myownEvent
method executed.
Security
Micrus handles both user's authentication (who are you?) and authorisation (do you have access to this page?).
There are two security-related services: Crypt and SecurityManager. The first one takes care of hashing, encrypting and decrypting, while the latter: of logging the user in or out, checking their credentials etc.
In Micrus's approach, the user doesn't just have one password, but might have multiple "Authenticators". They could be passwords, as well as "remember me" tokens stored in cookies or access tokens from OAuth, Facebook, Google+ etc.
You should create class App\Model\User
that implements interface Avris\Micrus\Model\UserInterface
and App\Model\Authenticator
that implements interface Avris\Micrus\Model\AuthenticatorInterface
.
Authorization restrictions
Sample security config:
security:
loginPath: login # default: login
afterLogout: home # default: home
rememberMeForDays: 7 # default: 14
restrictions:
- { pattern: ^/admin, roles: [ROLE_ADMIN] }
- { pattern: ^/post/add } # login required, but not any particular role
- { pattern: ^/post/(\d+)/edit, check: canEditPost }
- { controller: secret, roles: [ROLE_SECRET_KNOWER] }
- { controller: foobar/secret, roles: [ROLE_SECRET_KNOWER] }
Before every request, the SecurityManager checks if its URL matches any of the restrictions
.
If not, it continues to the controller. Otherwise:
- if user is not logged in, it redirects them to the path with name specified in
loginPath
, - if user is logged in, then
$user->getRoles()
must have at least one of those specified byroles
, otherwise 403 is thrown.
Additionally, SecurityManager can authorise user based on more complicated condition
(e.g. post can only be edited by its author), by invoking $user
's method as specified in check
(with $matches
as parameter):
// App\Model\User
public function canEditPost(Post $requestedPost)
{
if ($this->isAdmin()) {
return true;
}
foreach ($this->getPosts() as $postOfUser) {
if ($postOfUser == $requestedPost) {
return true;
}
}
return false;
}
Public paths
To declare some paths as public, regardless of other restrictions, just use public option.
For instance of you want all paths except /login
to require being logged in, use such configuration:
security:
loginPath: login
afterLogout: login
public:
- { pattern: ^/login }
restrictions:
- { pattern: ^/, roles: [ROLE_USER, ROLE_ADMIN] }
Role hierarchy
If you want all managers to automatically have all the permissions of normal users, and all admins to automatically have all the permissions of normal users and of managers, then configure those roles like this:
security:
roles:
ROLE_ADMIN: [ROLE_USER, ROLE_MANAGER]
ROLE_MANAGER: [ROLE_USER]
Security enhancements
security:
preventSessionFixation: true
cookiesOnlyHttp: true
encryptCookies: true
ssl: true
secureHeaders:
enabled: true
# check Avris\Micrus\Tool\Security\SecurityEnhancer::$headers for the list of those headers
UserProviders
Service userProvider
is used to retrieve user data from whatever source.
By default it's an instance of Avris\Micrus\Model\MemoryUserProvider
, which is read-only
and takes the list of users from @config.security.users
.
Such list should be defined in the following format:
security:
users:
admin:
roles: [ROLE_ADMIN]
authenticators:
password: 7282d579f3c1651f44065bdde0a801d0f05e110d2e4d835274e5429d96b75679bfb815021dd8b0e4
user:
roles: [ROLE_USER]
authenticators:
password: 7a2e81a01de38cfc51d6cf5afea59de2a11d031769974ef33536c21161ad66191fcd1bfec436326c
Note that you can generate password hashes using php bin/micrus security:hash
.
If you have an ORM module installed, it should automatically overwrite userProvider
to it's own,
which will retrieve user entity straight from the database.
Testing
Micrus recommends using PHPUnit for both unit and functional tests.
They should be put in the tests
directory and should keep the same namespace structure as in src
folder.
For the functional tests, Micrus provides a wrapper for Symfony BrowserKit and uses Symfony DomCrawler. So the usage is quite similar to what's presented in the Symfony documentation.
Remember to explicitly include dependencies:
composer require --dev symfony/browser-kit symfony/css-selector
To use it, you need to initialise the app for tests in your tests/_bootstrap.php
file:
$app = new \App\App('test', true);
\Avris\Micrus\Tool\Test\WebTestCase::init($app, __DIR__ . '/../tests/_output/fail');
The second argument is the directory where the HTTP responses of the failed tests will be saved for the purposes of debugging.
Your functional test cases have to extend Avris\Micrus\Tool\Test\WebTestCase
.
A client will be created for the whole test suite so that it can be shared between tests.
It is accessible via static::$client
.
You should create one story per class.
You can access the container via static::$client->getContainer()
.
For performance sake consider running your functional tests on a in-memory SQLite database or encapsulating your stories in a transaction.
For examples of functional and unit tests, please turn to the Micrus Demo Project.
Tools
Cacher
To boost performance in the production environment, Micrus caches config files, locales, Twig views and other data.
The cache
service uses the folder run/cache/{env}
to save cache, where {env}
is current environment.
It is PSR-6 compliant, which means you can easily switch it to any other caching method,
(Redis, Memcached etc.) as long as it also sticks to that standard.
There is also a cacher
service, that doesn't care for the actual storing of data (it uses cache
for that), but just
administrates, whether and how caching should be used. It can work in three modes:
-
normal - if the requested key exists in the cache and is still valid, we use cache, otherwise we generate the value
and store it in the cache to be used the next time; default in
prod
environment. -
always_miss - the content gets generated and put in cache every time it's requested (for testing purposes);
default in
test
environment. -
disabled - we don't use caching at all, content simply gets generated on the fly; default for
dev
environment.
To switch a mode, just set the parameter cacherMode
in app/Config/parameters.yml
.
You can use cacher
yourself:
$mydata = $this->getService('cacher')->cache('mydata', function($parameter) {
// load data somehow...
return $data;
}, [$parameter]);
It's that simple! Instead of running yourself the method which that loads your data, let Cacher wrap around it, and tell it what key to use. It will do its job, depending on its mode, and return the result.
If your service uses caching, you may consider implementing the listeners to events cacheClear
and cacheWarmup
.
They are triggered in, respectively: php bin/micrus cache:clear
and php bin/micrus cache:warmup
.
Note: the first one clears cache for all enviroments (to specifiy just one use php bin/micrus cache:clear -c --env=prod
)
and the latter runs in the default environment, cli
(to change it, run php bin/micrus cache:warmup --env=prod
).
You can automatically cache the services in your DI container by setting cached: true
:
service_name:
class: Acme\Service\Class
cached: true
Logger
The logger
service extends a great library, Monolog, which is
PSR-3 compliant, so you can switch it to any other that is as well.
For reference, please turn to Monolog's documentation.
If you don't specify a filename, default one will be used: simply {env}.log
.
You can configure the Logger with the following fields:
logger:
file: ~
round: ~
requestIdLength: ~
The round
entry can be either hour
, day
, week
, month
or year
, and it means
Logger will create separate log file for every given period of time.
Each log entry has a column for request ID. By default it's 4-character string,
and it can be customized with requestIdLength
.
Testing
Micrus is ready for use with PHPUnit, for both unit and functional tests.
Please put them in tests
directory and keep the same namespace structure as in app
folder.
For the functional tests, Micrus uses Symfony BrowserKit together with Symfony DomCrawler, so the usage is quite similar to what's presented in the Symfony documentation.
Remember to explicitly include dependencies:
composer require --dev symfony/browser-kit symfony/css-selector
Main difference is that your functional test cases should extend Avris\Micrus\Tool\Test\WebTestCase
.
A client will be created for the whole test class (accessible via static::$client
) so that it can be shared
between tests, creating one story per class. You can access the container via static::$client->getContainer()
.
Also, consider encapsulating your stories in transactions to improve performance and to keep database clean.
For examples of functional and unit tests, please turn to the Micrus Demo Project.
Extending Micrus with Modules
All the dependencies are handled by Composer.
Some libraries, called "Modules", are meant specifically for Micrus: they provide templates, services, extend routing etc.
File app/Config/modules.yml
contains a list of registered Modules and is loaded before any other config.
Module definition must implement the Avris\Micrus\Tool\Bootstrap\Module
interface, which only requires one method:
extendConfig($env, $rootDir)
. The arrays returned by all the configs will be merged with the config of your app.
The following hierarchy is kept in case one setting overwrites another:
modules < config.yml < imports < config_env.yml
Most of the times Module's config just extends the list of services. Sometimes they also add a new config namespace and declare the default values of what's inside. Take a look at the following example:
<?php
namespace Avris\Micrus\Assetic;
use Avris\Micrus\Bootstrap\Module;
class AsseticModule implements Module
{
public function extendConfig($env, $rootDir)
{
return [
'services' => [
'asseticManager' => [
'class' => AsseticManager::class,
'params' => ['@asseticGenerator', '#asseticExtension', '{@rootDir}/web', '@cacher', '@env'],
'events' => ['cacheClear', 'cacheWarmup'],
],
'appAsseticExtension' => [
'class' => AppAsseticExtension::class,
'params' => ['{@rootDir}/app/Asset', '@config.assetic'],
'tags' => ['asseticExtension'],
],
'asseticGenerator' => [
'class' => Generator::class,
],
'asseticTwigGlobals' => [
'class' => TwigGlobals::class,
'params' => ['@asseticManager'],
'tags' => ['twigExtension'],
],
],
'assetic' => [
'filters' => [],
'assets' => [],
'statics' => [],
],
];
}
}
Creating Modules looks almost the same as creating normal app components, except the config isn't kept in .yml
files,
but in the Module
class. If you want to add some templates, locales, console tasks or routes to your module,
you will have to specify the directories, where Micrus can find them. Just use the tags: templateDirs
, localeSet
,
task
and routingExtension
respectively.
You can create controllers inside modules, you'll just have to specify its full namespace in the routing
(controller: MyModule\Controllers\FooController
). But sometimes you might want a service to work as a controller
(as it is for instance in the localizator
service from the Localization Module:
it handles the localization, but also provides a single, small action just to switch current locale).
In that case declare route's controller as: @serviceName/foobarAction
. When that route is matched with the request,
service serviceName
will be retrieved from the container and its foobarAction
method will be executed.
Since your service is not an instance of Avris\Micrus\Controller\Controller
,
it doesn't have direct access to them Container or Request objects. But you can inject them straight to the action.
Just declare its first argument to be of an expected class, for instance:
public function switchLocaleForUserAction(Request $request, User $user, $localeName) { ... }