Tutorial - INVO¶
Overview¶
INVO is a small application that allows users to generate invoices, manage customers and products as well as sign up and log in. It showcases how certain tasks are handled by Phalcon. On the client side, Bootstrap is used for the UI. The application does not generate actual invoices but serves as an example of how these tasks are implemented using Phalcon.
NOTE
It is recommended that you open the application in your favorite editor so that you can follow this tutorial easier.
NOTE
Note the code below has been formatted to increase readability
Structure¶
You can clone the repository to your machine (or download it) from GitHub. Once you clone it (or download and unzip it) you will end up with the following directory structure:
└── invo
├── config
├── db
│ └── migrations
│ └── 1.0.0
├── docker
│ └── 8.0
│ └── 8.1
│── public
│ ├── index.php
│ └── js
├── src
│ ├── Controllers
│ ├── Forms
│ ├── Models
│ ├── Plugins
│ ├── Providers
├── themes
│ ├── about
│ ├── companies
│ ├── contact
│ ├── errors
│ ├── index
│ ├── invoices
│ ├── layouts
│ ├── products
│ ├── producttypes
│ ├── register
│ └── session
└── var
├── cache
└── logs
public/index.php
Once the application is set up, you can open it in your browser by navigating to the following URL https://localhost/invo
. You will see a screen similar to the one below:
The application is divided into two parts: a frontend and a backend. The front end is a public area where visitors can receive information about INVO and request contact information. The backend is an administrative area where registered users can manage their products and customers.
Routing¶
INVO uses the standard route that is built-in with the Router component. These routes match the following pattern:
The custom route /session/register
executes the controller SessionController
and its action registerAction
.
Configuration¶
Autoloader¶
For this application, we utilize the autoloader that comes with composer. You can easily adjust the code to use the autoloader provided by Phalcon if you wish:
DotEnv
¶
INVO uses the Dotenv\Dotenv
library to retrieve some configuration variables that are unique to each installation.
.env
file is present in your root directory. There is a .env.example
file that you can use as a reference and copy/rename it. Providers¶
We will need to register all the services we need for the application in a DI container. The framework provides a variant of Phalcon\Di\Di called Phalcon\Di\FactoryDefault. This class has pre-registered services to suit a full-stack MVC application. We therefore create a new Phalcon\Di\FactoryDefault
object and then call the provider classes to load the necessary services including the configuration of the application. They are all under the Providers
folder.
As an example, the Providers\ConfigProvider.php
class loads the config/config.php
file, which contains the configuration of the application:
<?php
namespace Invo\Providers;
use Exception;
use Phalcon\Di\DiInterface;
use Phalcon\Di\ServiceProviderInterface;
/**
* Read the configuration
*/
class ConfigProvider implements ServiceProviderInterface
{
public function register(DiInterface $di): void
{
$configPath = $di->offsetGet('rootPath') . '/config/config.php';
if (!file_exists($configPath) || !is_readable($configPath)) {
throw new Exception('Config file does not exist: ' . $configPath);
}
$di->setShared('config', function () use ($configPath) {
return require_once $configPath;
});
}
}
Phalcon\Config\Config allows us to manipulate the file in an object-oriented way. The configuration file has the following settings:
<?php
declare(strict_types=1);
use Phalcon\Config\Config;
return new Config([
'database' => [
'adapter' => $_ENV['DB_ADAPTER'] ?? 'Mysql',
'host' => $_ENV['DB_HOST'] ?? 'locahost',
'username' => $_ENV['DB_USERNAME'] ?? 'phalcon',
'password' => $_ENV['DB_PASSWORD'] ?? 'secret',
'dbname' => $_ENV['DB_DBNAME'] ?? 'phalcon_invo',
'charset' => $_ENV['DB_CHARSET'] ?? 'utf8',
],
'application' => [
'viewsDir' => $_ENV['VIEWS_DIR'] ?? 'themes/invo',
'baseUri' => $_ENV['BASE_URI'] ?? '/',
],
]);
Phalcon does not have a convention for defining settings. Sections help us to organize the options based on groups that make sense for our application. In our file, there are two sections that will be used later on: application
and database
.
Request Handling¶
At the end of the file (public/index.php
), the request is finally handled by Phalcon\Mvc\Application, which initializes all the services necessary for the application to run.
<?php
use Phalcon\Mvc\Application;
// ...
/**
* Init MVC Application and send output to the client
*/
(new Application($di))
->handle($_SERVER['REQUEST_URI'])
->send()
;
Dependency Injection¶
In the first line of the code block above, the Application class constructor receives the variable $container
as an argument.
Since Phalcon is highly decoupled, we need the container to be able to access registered services from it in different parts of the application. The component in question is Phalcon\Di\Di. It is a service container, that also performs dependency injection and service location, instantiating all components as they are needed by the application.
There are many ways available to register services in the container. In INVO, most services have been registered using anonymous functions/closures. Thanks to this, the objects are lazy loaded, reducing the resources required by the application to a bare minimum.
For instance, in the following excerpt the Providers\SessionProvider
service is registered. The anonymous function will only be called when the application requires access to the session data:
<?php
use Phalcon\Session\Adapter\Stream as SessionAdapter;
use Phalcon\Session\Manager as SessionManager;
$di->setShared(
'session',
function () {
$session = new SessionManager();
$files = new SessionAdapter(
[
'savePath' => sys_get_temp_dir(),
]
);
$session->setAdapter($files);
$session->start();
return $session;
}
);
Here, we have the freedom to change the adapter, perform additional initialization, and much more. Note that the service was registered using the name session
. This is a convention that will allow the framework to identify the active service in the DI container.
Log in¶
A log in
page will allow us to work with the backend controllers. The separation between backend controllers and frontend ones is arbitrary. All controllers are located in the same directory (src/Controllers/
).
To enter the system, users must have a valid username and password. User data is stored in the table users
in the database invo
.
Now we need to configure the connection to the database. A service called db
is set up in the service container with the connection information. As with the autoloader, we are again taking parameters from the configuration file in order to configure the service:
<?php
// ...
$dbConfig = $di->getShared('config')
->get('database')
->toArray()
;
$di->setShared('db', function () use ($dbConfig) {
$dbClass = 'Phalcon\Db\Adapter\Pdo\\' . $dbConfig['adapter'];
unset($dbConfig['adapter']);
return new $dbClass($dbConfig);
});
Here, we return an instance of the MySQL connection adapter, because the $dbConfig['adapter']
setting is Mysql
. We can also add extra functionality, such as adding a [Logger][logger], a profiler to measure query execution times or even change the adapter to a different RDBMS.
The following simple form (themes/invo/session/index.volt
) produces the necessary HTML so that users can submit login information. Some HTML code has been removed to improve readability:
<div>
<div>
<form action="/session/start" role="form" method="post">
<fieldset>
<div class="form-group">
<label for="email">Username/Email</label>
<div class="controls">
{{ text_field('email', 'class': "form-control") }}
</div>
</div>
<div class="form-group">
<label for="password">Password</label>
<div class="controls">
{{ password_field('password', 'class': "form-control") }}
</div>
</div>
<div class="form-group">
{{ submit_button('Login', 'class': 'btn btn-primary btn-large') }}
</div>
</fieldset>
</form>
</div>
<div class="col-md-6">
<div class="clearfix center">
{{ link_to('register', 'Sign Up', 'class': 'btn btn-primary btn-large btn-success') }}
</div>
</div>
</div>
We are using Volt as our template engine instead of PHP. This is a built-in template engine inspired by Jinja providing a simple and user-friendly syntax to create templates. If you have worked with Jinja or Twig in the past, you will see many similarities.
The SessionController::startAction
function (src/Controllers/SessionController.php
) validates the data submitted from the form, and also checks for a valid user in the database:
<?php
use Invo\Models\Users;
class SessionController extends ControllerBase
{
// ...
/**
* This action authenticate and logs a user into the application
*/
public function startAction(): void
{
if ($this->request->isPost()) {
$email = $this->request->getPost('email');
$password = $this->request->getPost('password');
/** @var Users|null $user */
$user = Users::findFirst([
"(email = :email: OR username = :email:) AND "
. "password = :password: AND active = 'Y'",
'bind' => [
'email' => $email,
'password' => sha1($password),
],
]);
if ($user) {
$this->registerSession($user);
$this->flash->success('Welcome ' . $user->name);
$this->dispatcher->forward(
[
'controller' => 'invoices',
'action' => 'index',
]
);
return;
}
$this->flash->error('Wrong email/password');
}
$this->dispatcher->forward(
[
'controller' => 'session',
'action' => 'index',
]
);
}
/**
* Register an authenticated user into session data
*
* @param Users $user
*/
private function registerSession(Users $user): void
{
$this->session->set(
'auth',
[
'id' => $user->id,
'name' => $user->name,
]
);
}
}
At first inspection of the code, you will note that several public properties are accessed in the controller, such as $this->flash
, $this->request
, or $this->session
. Controllers in Phalcon are automatically tied to the Phalcon\Di\Di container and as a result, all the services registered in the container are present in each controller as properties with the same name as the name of each service. If the service is accessed for the first time, it will be automatically instantiated and returned to the caller. Additionally, these services are set as shared so the same instance will be returned, no matter how many times we access the property/service in the same request. These are services defined in the services container from earlier (Providers
folder) and you can of course change this behavior when setting up these services.
For instance, here we invoke the session
service, and then we store the user identity in the variable auth
:
NOTE
For more information about Di services, please check the Dependency Injection document.
The startAction
first checks if data has been submitted using a POST
. If not, the user will be redirected again to the same form. We are checking if the form has been submitted via POST
using the isPost()
method on the request object.
We then retrieve posted data from the request. These are the text boxes that are used to submit the form when the user clicks Log In
. We use the request
object and getPost()
method.
Now, we have to check if we have an active user with the submitted email and password:
<?php
$user = Users::findFirst(
[
"(email = :email: OR username = :email:) " .
"AND password = :password: " .
"AND active = 'Y'",
'bind' => [
'email' => $email,
'password' => sha1($password),
]
]
);
NOTE
Note, the use of 'bound parameters', placeholders :email:
and :password:
are placed where values should be, then the values are bound using the parameter bind
. This safely replaces the values for those columns without the risk of a SQL injection.
When searching for the user in the database, we are not searching for the password directly using clear text. The application stores passwords as hashes, using the sha1 method. Although this methodology is adequate for a tutorial, you might want to consider using a different algorithm for a production application. The Phalcon\Encryption\Security component offers convenience methods to strengthen the algorithm used for your hashes.
If the user is found, then we register the user in the session (log the user in) and forward them to the dashboard (Invoices
controller, index
action) showing a welcome message.
<?php
if ($user) {
$this->registerSession($user);
$this->flash->success('Welcome ' . $user->name);
$this->dispatcher->forward([
'controller' => 'invoices',
'action' => 'index',
]);
return;
}
If the user is not found, we forward them to the login page with a Wrong email/password
message on the screen.
Backend Security¶
The backend is a private area where only registered users have access. Therefore, it is necessary to check that only registered users have access to these controllers. If you are not logged in and try to access a private area you will see a message like the one below:
Every time a user attempts to access any controller/action, the application verifies that the current role (stored in the session) has access to it, otherwise it displays a message as shown above and forwards the flow to the home page.
In order to accomplish this, we need to use the Dispatcher component. When the user requests a page or URL, the application first identifies the page requested using the Route component. Once the route has been identified and matched to a valid controller and action, this information is delegated to the Dispatcher which then loads the controller and executes the action.
Normally, the framework creates the Dispatcher automatically. In our case, we need to verify that the user is logged in before the route is dispatched. As such we need to replace the default component in the DI container and set a new one in (Providers\DispatchProvider.php
). We do this when bootstrapping the application:
<?php
use Phalcon\Mvc\Dispatcher;
// ...
$di->setShared(
'dispatcher',
function () {
// ...
$dispatcher = new Dispatcher();
$dispatcher->setDefaultNamespace('Invo\Controllers');
// ...
return $dispatcher;
}
);
By creating an [Events Manager][events] and attaching specific code to the dispatcher
events, we now have a lot more flexibility and can attach our code to the dispatch loop or operation.
Events¶
The [Events Manager][events] allows us to attach listeners to a particular type of event. The event type that we are attaching to is dispatch
. The code below attaches listeners to the beforeExecuteRoute
and beforeException
events. We utilize these events to check for 404 pages and also perform allowed access checks in our application.
<?php
use Invo\Plugins\NotFoundPlugin;
use Invo\Plugins\SecurityPlugin;
use Phalcon\Events\Manager as EventsManager;
use Phalcon\Mvc\Dispatcher;
$di->setShared(
'dispatcher',
function () {
$eventsManager = new EventsManager();
/**
* Check if the user is allowed to access certain actions using
* the SecurityPlugin
*/
$eventsManager->attach(
'dispatch:beforeExecuteRoute',
new SecurityPlugin()
);
/**
* Handle exceptions and not-found exceptions using NotFoundPlugin
*/
$eventsManager->attach(
'dispatch:beforeException',
new NotFoundPlugin()
);
$dispatcher = new Dispatcher();
$dispatcher->setDefaultNamespace('Invo\Controllers');
$dispatcher->setEventsManager($eventsManager);
return $dispatcher;
}
);
When an event called beforeExecuteRoute
is triggered the SecurityPlugin
plugin will be notified:
When a beforeException
is triggered then the NotFoundPlugin
is notified:
SecurityPlugin
is a class located in the Plugins
directory (src/Plugins/SecurityPlugin.php
). This class implements the method beforeExecuteRoute
. This is the same name as one of the events produced in the Dispatcher:
<?php
use Phalcon\Di\Injectable;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher;
class SecurityPlugin extends Injectable
{
// ...
public function beforeExecuteRoute(
Event $event,
Dispatcher $containerspatcher
) {
// ...
}
}
$containerspatcher
). It is not mandatory that plugin classes extend the class Phalcon\Di\Injectable, but by doing this they gain easier access to the services available in the application. We now have the structure to start verifying the role in the current session. We can check if the user has access to the [ACL][acl]. If the user does not have access, we will redirect them to the home screen.
<?php
use Phalcon\Di\Injectable;
use Phalcon\Events\Event;
use Phalcon\Mvc\Dispatcher;
class SecurityPlugin extends Plugin
{
// ...
public function beforeExecuteRoute(
Event $event,
Dispatcher $containerspatcher
) {
$auth = $this->session->get('auth');
if (!$auth) {
$role = 'Guests';
} else {
$role = 'Users';
}
$controller = $dispatcher->getControllerName();
$action = $dispatcher->getActionName();
$acl = $this->getAcl();
if (!$acl->isComponent($controller)) {
$dispatcher->forward(
[
'controller' => 'errors',
'action' => 'show404',
]
);
return false;
}
$allowed = $acl->isAllowed($role, $controller, $action);
if (!$allowed) {
$dispatcher->forward(
[
'controller' => 'errors',
'action' => 'show401',
]
);
$this->session->destroy();
return false;
}
return true;
}
}
auth
value from the session
service. If we are logged in, then this has been already set for us during the login process. If not, we are just a guest. Following that, we get the name of the controller and the action, and also retrieve the Access Control List (ACL). We check if the user isAllowed
using the combination role
- controller
- action
. If yes, the method will finish processing.
If we do not have access, then the method will return false
stopping the execution, right after we forward the user to the home page.
ACL¶
In the above example, we have obtained the ACL using the method $this->getAcl()
. To build the ACL we need to do the following:
<?php
use Phalcon\Acl\Enum;
use Phalcon\Acl\Role;
use Phalcon\Acl\Adapter\Memory as AclList;
$acl = new AclList();
$acl->setDefaultAction(Enum::DENY);
$roles = [
'users' => new Role(
'Users',
'Member privileges, granted after sign-in.'
),
'guests' => new Role(
'Guests',
'Anyone browsing the site who is not signed in is considered to be a "Guest".'
)
];
foreach ($roles as $role) {
$acl->addRole($role);
}
Phalcon\Acl\Adapter\Memory
object. Although the default access is DENY
we still set it in our list by using setDefaultAction()
. After that, we need to set up our roles. For INVO we have guests
(users that have not been logged in) and users
. We register those roles by using addRole
on the list. Now that the roles are set, we need to set the components for the list. ACL components map to the areas of our application (controller/action). Doing so we can control which role can access which component.
<?php
use Phalcon\Acl\Component;
// ...
$privateComponents = [
'companies' => [
'index',
'search',
'new',
'edit',
'save',
'create',
'delete',
],
'products' => [
'index',
'search',
'new',
'edit',
'save',
'create',
'delete',
],
'producttypes' => [
'index',
'search',
'new',
'edit',
'save',
'create',
'delete',
],
'invoices' => [
'index',
'profile',
],
];
foreach ($privateComponents as $componentName => $actions) {
$acl->addComponent(
new Component($componentName),
$actions
);
}
$publicComponents = [
'index' => [
'index',
],
'about' => [
'index',
],
'register' => [
'index',
],
'errors' => [
'show404',
'show500',
],
'session' => [
'index',
'register',
'start',
'end',
],
'contact' => [
'index',
'send',
],
];
foreach ($publicComponents as $componentName => $actions) {
$acl->addComponent(
new Component($componentName),
$actions
);
}
Now that roles and components are registered, we need to link the two so that the ACL is complete. The Users
role has access to public (frontend) and private (backend) components, while Guests
only have access to the public (frontend) components.
<?php
// Grant access to public areas to both users and guests
foreach ($roles as $role) {
foreach ($publicResources as $resource => $actions) {
foreach ($actions as $action) {
$acl->allow($role->getName(), $resource, $action);
}
}
}
// Grant access to private area to role Users
foreach ($privateResources as $resource => $actions) {
foreach ($actions as $action) {
$acl->allow('Users', $resource, $action);
}
}
CRUD¶
A backend portion of an application is the code that provides forms and logic, allowing users to manipulate data i.e. perform CRUD operations. We will explore how INVO handles this task and also demonstrate the use of forms, validators, paginators, and more.
We have a simple CRUD (Create, Read, Update, and Delete) implementation in INVO, to manipulate data (companies, products, types of products). For products the following files are used:
└── invo
└── src
├── Controllers
│ └── ProductsController.php
├── Forms
│ └── ProductsForm.php
├── Models
│ └── Products.php
└── themes
└── invo
└── products
├── edit.volt
├── index.volt
├── new.volt
└── search.volt
Company
) can be found in the same directories as shown above. Each controller has the following actions:
<?php
class ProductsController extends ControllerBase
{
public function createAction();
public function editAction($id);
public function deleteAction($id);
public function indexAction();
public function newAction();
public function saveAction();
public function searchAction();
}
Action | Description |
---|---|
createAction | Creates a product based on the data entered in the new action |
deleteAction | Deletes an existing product |
editAction | Shows the view to edit an existing product |
indexAction | The start action, shows the search view |
newAction | Shows the view to create a new product |
saveAction | Updates a product based on the data entered in the edit action |
searchAction | Execute the search based on the criteria sent from the index . Returning a paginator for the results |
Search Form¶
Our CRUD operations start with the search form. This form shows each field that the table has (products
), allowing the user to enter search criteria for each field. The products
table has a relationship with the table products_types
. In this case, we previously queried the records in the product_types
table to offer search criteria for this field:
<?php
public function indexAction()
{
$this->persistent->searchParams = null;
$this->view->form = new ProductsForm();
}
ProductsForm
form (src/Forms/ProductsForm.php
) is passed to the view. This form defines the fields that are visible to the user: <?php
use Phalcon\Forms\Form;
use Phalcon\Forms\Element\Text;
use Phalcon\Forms\Element\Hidden;
use Phalcon\Forms\Element\Select;
use Phalcon\Validation\Validator\Email;
use Phalcon\Validation\Validator\PresenceOf;
use Phalcon\Validation\Validator\Numericality;
class ProductsForm extends Form
{
public function initialize($entity = null, $options = [])
{
if (!isset($options['edit'])) {
$this->add((new Text('id'))->setLabel('Id'));
} else {
$this->add(new Hidden('id'));
}
/**
* Name text field
*/
$name = new Text('name');
$name->setLabel('Name');
$name->setFilters(['striptags', 'string']);
$name->addValidators([
new PresenceOf(
[
'message' => 'Name is required'
]
),
]);
$this->add($name);
/**
* Product Type ID Select
*/
$type = new Select(
'product_types_id',
ProductTypes::find(),
[
'using' => ['id', 'name'],
'useEmpty' => true,
'emptyText' => '...',
'emptyValue' => '',
]
);
$type->setLabel('Type');
$this->add($type);
/**
* Price text field
*/
$price = new Text('price');
$price->setLabel('Price');
$price->setFilters(['float']);
$price->addValidators([
new PresenceOf(
[
'message' => 'Price is required'
]
),
new Numericality(
[
'message' => 'Price is required'
]
),
]);
$this->add($price);
}
}
The form is declared using an object-oriented scheme based on the elements provided by the [Phalcon\Forms\Form][forms] component. Each element defined follows almost the same setup:
<?php
$name = new Text('name');
$name->setLabel('Name');
$name->setFilters(
[
'striptags',
'string',
]
);
$name->addValidators(
[
new PresenceOf(
[
'message' => 'Name is required',
]
)
]
);
$this->add($name);
Other elements are also used in this form:
<?php
$this->add(
new Hidden('id')
);
// ...
$productTypes = ProductTypes::find();
$type = new Select(
'profilesId',
$productTypes,
[
'using' => [
'id',
'name',
],
'useEmpty' => true,
'emptyText' => '...',
'emptyValue' => '',
]
);
id
of the product if applicable. We also get all the product types by using the ProductTypes::find()
and then use that resultset to fill the HTML select
element by using the [Phalcon\Tag][tag] component and its select()
method. Once the form is passed to the view, it can be rendered and presented to the user: <div class="row mb-3">
<div class="col-xs-12 col-md-6">
<h2>Search products</h2>
</div>
<div class="col-xs-12 col-md-6 text-right">
{{ link_to("products/new", "Create Product", "class": "btn btn-primary") }}
</div>
</div>
<form action="/products/search" role="form" method="get">
{% for element in form %}
{% if is_a(element, 'Phalcon\Forms\Element\Hidden') %}
{{ element }}
{% else %}
<div class="form-group">
{{ element.label() }}
<div class="controls">
{{ element.setAttribute("class", "form-control") }}
</div>
</div>
{% endif %}
{% endfor %}
{{ submit_button("Search", "class": "btn btn-primary") }}
</form>
This produces the following HTML:
<form action='/invo/products/search' method='post'>
<h2>
Search products
<div class="col-xs-12 col-md-6 text-right">
<a href="products/new" "class=btn btn-primary">Create Product</a>
</div>
</h2>
<fieldset>
<div class='control-group'>
<label for='id' class='control-label'>Id</label>
<div class='controls'>
<input type='text' id='id' name='id' />
</div>
</div>
<div class='control-group'>
<label for='name' class='control-label'>Name</label>
<div class='controls'>
<input type='text' id='name' name='name' />
</div>
</div>
<div class='control-group'>
<label for='profilesId' class='control-label'>
profilesId
</label>
<div class='controls'>
<select id='profilesId' name='profilesId'>
<option value=''>...</option>
<option value='1'>Vegetables</option>
<option value='2'>Fruits</option>
</select>
</div>
</div>
<div class='control-group'>
<label for='price' class='control-label'>Price</label>
<div class='controls'>
<input type='text' id='price' name='price' />
</div>
</div>
<div class='control-group'>
<input type='submit'
value='Search'
class='btn btn-primary' />
</div>
</fieldset>
</form>
When the form is submitted, the search
action is executed in the controller performing the search based on the data entered by the user.
Search¶
The search
action has two operations. When accessed using the HTTP method POST
, it performs the search based on the data sent from the form. When it is accessed using the HTTP method GET
, it moves the current page in the paginator. To check which HTTP method has been used, we use the [Request][request] component:
<?php
public function searchAction()
{
if ($this->request->isPost()) {
// POST
} else {
// GET
}
// ...
}
With the help of Phalcon\Mvc\Model\Criteria, we can create the search conditions based on the data types and values sent from the form:
This method verifies which values are different from '' (empty string) and null
and takes them into account to create the search criteria:
- If the field data type is
text
or similar (char
,varchar
,text
, etc.) It uses an SQLlike
operator to filter the results. - If the data type is not
text
or similar, it will use the operator=
.
Additionally, Criteria
ignores all the $_POST
variables that do not match any field in the table. Values are automatically escaped using bound parameters
.
Now, we store the produced parameters in the controller's session bag:
A session bag, (persistent
property) is a special attribute in a controller that persists data between requests using the session service. When accessed, this attribute injects a Phalcon\Session\Bag instance that is independent in each controller.
Then, based on the built params we perform the query:
<?php
$products = Products::find($parameters);
if (count($products) === 0) {
$this->flash->notice(
'The search did not find any products'
);
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
If the search does not return any product, we forward the user to the index
action again. If the search returns results, we pass them to a paginator object so that we can navigate through chunks of resultsets:
<?php
use Phalcon\Paginator\Adapter\Model as Paginator;
// ...
$paginator = new Paginator(
[
'data' => $products,
'limit' => 5,
'page' => $numberPage,
]
);
$page = $paginator->paginate();
paginate()
to get the appropriate chunk of the resultset back. We then pass the returned page to view:
In the view (themes/invo/products/search.volt
), we traverse the results corresponding to the current page, showing every row in the current page to the user:
{% for product in page.items %}
{% if loop.first %}
<table class="table table-bordered table-striped" align="center">
<thead>
<tr>
<th>Id</th>
<th>Product Type</th>
<th>Name</th>
<th>Price</th>
<th>Active</th>
</tr>
</thead>
<tbody>
{% endif %}
<tr>
<td>{{ product.id }}</td>
<td>{{ product.getProductTypes().name }}</td>
<td>{{ product.name }}</td>
<td>${{ "%.2f"|format(product.price) }}</td>
<td>{{ product.getActiveDetail() }}</td>
<td width="7%">
{{
link_to(
"products/edit/" ~ product.id,
'<i class="glyphicon glyphicon-edit"></i> Edit',
"class": "btn btn-default"
)
}}
</td>
<td width="7%">
{{
link_to(
"products/delete/" ~ product.id,
'<i class="glyphicon glyphicon-remove"></i> Delete',
"class": "btn btn-default"
)
}}
</td>
</tr>
{% if loop.last %}
</tbody>
<tbody>
<tr>
<td colspan="7" align="right">
<div class="btn-group">
{{
link_to(
"products/search",
'<i class="icon-fast-backward"></i> First',
"class": "btn"
)
}}
{{
link_to(
"products/search?page=" ~ page.before,
'<i class="icon-step-backward"></i> Previous',
"class": "btn"
)
}}
{{
link_to(
"products/search?page=" ~ page.next,
'<i class="icon-step-forward"></i> Next',
"class": "btn"
)
}}
{{
link_to(
"products/search?page=" ~ page.last,
'<i class="icon-fast-forward"></i> Last',
"class": "btn"
)
}}
<span class="help-inline">
{{ page.current }} of {{ page.total_pages }}
</span>
</div>
</td>
</tr>
</tbody>
</table>
{% endif %}
{% else %}
No products are recorded
{% endfor %}
Looking at the code above it is worth mentioning:
The active items in the current page are traversed using a Volt's for
. Volt provides a simpler syntax for a PHP foreach
.
Which in PHP is the same as:
The whole for
block is:
{% for product in page.items %}
{% if loop.first %}
// Executed before the first product in the loop
{% endif %}
// Executed for every product on `page.items`
{% if loop.last %}
// Executed after the last product in the loop
{% endif %}
{% else %}
// Executed if `page.items` does not have any products
{% endfor %}
Now you can go back to the view and find out what every block is doing. Every field in product
is printed accordingly:
<tr>
<td>
{{ product.id }}
</td>
<td>
{{ product.getProductTypes().name }}
</td>
<td>
{{ product.name }}
</td>
<td>
{{ '%.2f'|format(product.price) }}
</td>
<td>
{{ product.getActiveDetail() }}
</td>
<td width='7%'>
{{ link_to('products/edit/' ~ product.id, 'Edit') }}
</td>
<td width='7%'>
{{ link_to('products/delete/' ~ product.id, 'Delete') }}
</td>
</tr>
As we have seen before using product.id
is the same as in PHP as doing: $product->id
, we made the same with product.name
and so on. Other fields are rendered differently, for instance, let's focus in product.getProductTypes().name
. To understand this part, we have to check the Products model (app/models/Products.php
):
<?php
use Phalcon\Mvc\Model;
/**
* Products
*/
class Products extends Model
{
// ...
public function initialize()
{
$this->belongsTo(
'product_types_id',
'ProductTypes',
'id',
[
'reusable' => true,
]
);
}
// ...
}
A model can have a method called initialize()
, this method is called once per request, and it serves the ORM to initialize a model. In this case, Products
is initialized by defining that this model has a one-to-many relationship to another model called ProductTypes
.
product_types_id
in Products
has a one-to-many relation to the ProductTypes
model in its attribute id
. By defining this relationship we can access the name of the product type by using: The field price
is printed by its formatted using a Volt filter:
In plain PHP, this would be:
Printing whether the product is active or not uses a helper method:
This method is implemented in the model.
Create/Update¶
When creating and updating records, we use the new
and edit
views. The data entered by the user is sent to the create
and save
actions that perform actions of creating and updating products, respectively.
In the creation case, we get the data submitted and assign them to a new Products
instance:
<?php
public function createAction()
{
if (true !== $this->request->isPost()) {
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
$form = new ProductsForm();
$product = new Products();
$product->id = $this
->request
->getPost('id', 'int')
;
$product->product_types_id = $this
->request
->getPost('product_types_id', 'int')
;
$product->name = $this
->request
->getPost('name', 'striptags')
;
$product->price = $this
->request
->getPost('price', 'double')
;
$product->active = $this
->request
->getPost('active')
;
// ...
}
<?php
// ...
$name = new Text('name');
$name->setLabel('Name');
$name->setFilters(
[
'striptags',
'string',
]
);
$name->addValidators(
[
new PresenceOf(
[
'message' => 'Name is required',
]
)
]
);
$this->add($name);
Upon saving the data, we will know whether the business rules and validations implemented in the ProductsForm
pass (src/Forms/ProductsForm.php
):
<?php
// ...
$form = new ProductsForm();
$product = new Products();
$data = $this->request->getPost();
if (true !== $form->isValid($data, $product)) {
$messages = $form->getMessages();
foreach ($messages as $message) {
$this->flash->error($message->getMessage());
}
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'new',
]
);
}
Calling $form->isValid()
invokes all the validators set in the form. If the validation does not pass, the $messages
variable will contain the relevant messages of the failed validations.
If there are no validation errors, we can save the record:
<?php
// ...
if ($product->save() === false) {
$messages = $product->getMessages();
foreach ($messages as $message) {
$this->flash->error($message->getMessage());
}
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'new',
]
);
}
$form->clear();
$this->flash->success(
'Product was created successfully'
);
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
We are checking the result of the save()
method on the model and if errors occur, they will be present in the $messages
variable and the user will be sent back to the products/new
action with error messages displayed. If everything is OK, the form is cleared and the user is redirected to the products/index
with the relevant success message.
In the case of updating a product, we must first get the relevant record from the database and then populate the form with the existing data:
<?php
public function editAction($id)
{
if (true !== $this->request->isPost()) {
$product = Products::findFirstById($id);
if (null !== $product) {
$this->flash->error(
'Product was not found'
);
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
$this->view->form = new ProductsForm(
$product,
[
'edit' => true,
]
);
}
}
The data found is bound to the form by passing the model as the first parameter. Because of this, the user can change any value and then send it back to the database through to the save
action:
<?php
public function saveAction()
{
if (true !== $this->request->isPost()) {
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
$id = $this->request->getPost('id', 'int');
$product = Products::findFirstById($id);
if (null !== $product) {
$this->flash->error(
'Product does not exist'
);
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
$form = new ProductsForm();
$data = $this->request->getPost();
if (true !== $form->isValid($data, $product)) {
$messages = $form->getMessages();
foreach ($messages as $message) {
$this->flash->error($message->getMessage());
}
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'new',
]
);
}
if (false === $product->save()) {
$messages = $product->getMessages();
foreach ($messages as $message) {
$this->flash->error($message->getMessage());
}
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'new',
]
);
}
$form->clear();
$this->flash->success(
'Product was updated successfully'
);
$this->dispatcher->forward(
[
'controller' => 'products',
'action' => 'index',
]
);
}
Dynamic Titles¶
When you navigate through the application, you will see that the title changes dynamically indicating where we are currently working. This is achieved in each controller (initialize()
method):
<?php
class ProductsController extends ControllerBase
{
public function initialize()
{
parent::initialize();
$this->tag->title()
->set('Manage your products')
;
}
// ...
}
Note, that the method parent::initialize()
is also called, it adds more data to the title:
<?php
use Phalcon\Mvc\Controller;
class ControllerBase extends Controller
{
protected function initialize()
{
$this->tag->title()
->prepend('INVO | ')
;
$this->view->setTemplateAfter('main');
}
// ...
}
Finally, the title is printed in the main view (themes/invo/views/index.volt
):