Guide: Building a Comprehensive Module in Magento 2

Magento holds the title of the biggest open-source eCommerce platform globally. Its adaptable and feature-rich codebase has made it a favorite for businesses of all sizes, powering a diverse range of online stores.

Magento 1 enjoyed a long reign of eight years before its successor, Magento 2, arrived in late 2015. This new iteration addressed some shortcomings of its predecessor, boasting:

  • Enhanced performance
  • A dedicated automated testing suite
  • A more user-friendly backend interface
  • A modern front-end codebase built with newer technologies
  • Improved module development with a modular approach, keeping files organized within the Magento codebase
  • Minimized conflicts arising from modules attempting to modify the same functionalities
A stylized Magento 2 logo

Just over a year later, the progress is evident, even if not all identified issues have been completely resolved. Magento 2 undeniably stands as a more reliable and robust platform compared to its forerunner. Among its notable improvements are:

  • Unit and integration tests, including official documentation and guidelines for creating them for customized modules
  • Truly modularized modules with all related files neatly organized within a single directory
  • A more versatile templating system empowering theme developers to create multi-level template hierarchies
  • The implementation of beneficial design patterns across the codebase, enhancing code quality and reducing the chance of module-induced errors. These include, but are not limited to, automatic dependency injection, service contracts, repositories, and factories
  • Seamless integration with Varnish for full-page caching and Redis for session and cache management
  • Support for PHP 7

With these advancements, Magento 2 presents a steeper learning curve. This Magento 2 development guide aims to simplify this journey by demonstrating the creation of your first Magento 2 module and setting you on the right track for further exploration. So, let’s dive in!

Getting Started with Magento 2 Development: Prerequisites

Before we proceed, a solid understanding of the following technologies and concepts is essential to grasp the remainder of this article:

  • Object-oriented Programming (OOP)
  • PHP
  • Namespaces
  • MySQL
  • Basic bash commands

Among these, OOP holds particular significance. Magento’s initial development team comprised experienced Java developers, and their influence is clearly reflected in the codebase. If your OOP skills are a little rusty, it’s advisable to brush up on them before delving into Magento development.

Unraveling Magento 2’s Architecture

Magento’s architecture prioritizes modularity and extensibility in its source code. This design philosophy aims to facilitate easy adaptation and customization to meet the specific needs of each project.

Customization typically involves altering the behavior of the platform’s code. In most systems, this translates to modifying the “core” code. However, Magento, when adhering to best practices, often allows you to circumvent this, enabling stores to seamlessly stay up-to-date with the latest security patches and feature releases.

Magento 2 embraces the Model View ViewModel (MVVM) architecture. Although closely related to Model View Controller (MVC), MVVM provides a more distinct separation between the Model and View layers. Let’s break down each layer in an MVVM system:

  • The Model governs the business logic of the application, relying on a companion class, the ResourceModel, for database interactions. Models leverage service contracts to expose their functionality to other application layers.
  • The View represents the visual structure and layout that a user encounters – the actual HTML. This is achieved through PHTML files distributed with modules. These PHTML files are linked to their corresponding ViewModels in Layout XML files, known as binders in MVVM terminology. Layout files may also associate JavaScript files for use in the final rendered page.
  • The ViewModel acts as an intermediary between the Model and View layers, presenting only the essential information to the View. In Magento 2, this role is handled by the module’s Block classes. Notably, this responsibility often falls under the Controller’s purview in an MVC system. In MVVM, the controller primarily manages user flow, receiving requests and instructing the system to either render a view or redirect the user to a different route.

A Magento 2 module typically incorporates several, if not all, elements of the architecture outlined above. The overall architecture can be visualized as follows (source):

Diagram of full Magento 2 architecture

Magento 2 modules can, in turn, define external dependencies using Composer, PHP’s dependency manager. As depicted in the diagram, Magento 2 core modules rely on Zend Framework, Symfony, and other third-party libraries.

To illustrate, let’s examine the structure of Magento/Cms, a core module responsible for managing the creation of pages and static blocks.

Directory layout of Magento/Cms module

Each folder houses a specific aspect of the architecture:

  • Api: Contains service contracts defining service and data interfaces
  • Block: Houses the ViewModels of our MVVM architecture
  • Controller: Contains controllers responsible for handling user interactions and flow within the system
  • etc: Stores configuration XML files. The module defines itself and its components (routes, models, blocks, observers, cron jobs) within this folder. Non-core modules can also use etc files to override core module functionalities.
  • Helper: Holds helper classes containing code used across multiple application layers. For instance, in the Cms module, helper classes prepare HTML for browser presentation.
  • i18n: Stores internationalization CSV files used for translation purposes
  • Model: Contains Models and ResourceModels
  • Observer: Holds Observers, which are Models “observing” system events. When an event is triggered, the observer typically instantiates a Model to handle the associated business logic.
  • Setup: Contains migration classes responsible for schema and data creation
  • Test: Holds unit tests
  • Ui: Contains UI elements like grids and forms used in the admin application
  • view: Stores layout (XML) files and template (PHTML) files for both the front-end and admin application

It’s worth noting that, in practice, Magento 2’s internal workings reside entirely within modules. The image above highlights examples like Magento_Checkout, managing the checkout process, and Magento_Catalog, handling products and categories. Essentially, mastering module development is paramount to becoming a proficient Magento 2 developer.

Having covered the basics of system architecture and module structure, let’s transition to a more practical exercise. We’ll walk through a traditional Weblog tutorial to familiarize you with Magento 2 development. But first, we need to set up a development environment. Let’s get started!

Setting up Your Magento 2 Module Development Environment

At the time of this writing, the official Magento 2 DevBox, a Docker-based solution, was available. However, Docker on macOS, especially for I/O-intensive systems like Magento 2, remains less than ideal. Therefore, we’ll opt for a native installation, setting up all packages directly on our machine.

Server Setup

While a native installation might seem more involved, it yields a significantly faster Magento development environment. Trust me, bypassing Docker for Magento 2 development will save you countless hours.

This tutorial assumes a macOS environment with Brew installed. If your setup differs, the fundamental steps remain the same; you’ll only need to adjust the package installation methods accordingly. Let’s begin by installing the necessary packages:

1
brew install mysql nginxb php70 php70-imagick php70-intl php70-mcrypt

Next, start the services:

1
2
3
brew services start mysql
brew services start php70
sudo brew services start nginx

Now, let’s point a domain to our loopback address. Open the hosts file using your preferred editor, ensuring you have superuser permissions. With Vim, this would be:

1
sudo vim /etc/hosts

Add the following line:

1
127.0.0.1       magento2.dev

Next, create a vhost in Nginx:

1
vim /usr/local/etc/nginx/sites-available/magento2dev.conf

Add the following content:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
server {
  listen 80;

  server_name magento2.dev;

  set $MAGE_ROOT /Users/yourusername/www/magento2dev;
  set $MAGE_MODE developer;


  # Default magento Nginx config starts below
  root $MAGE_ROOT/pub;
  index index.php;
  autoindex off;
  charset off;

  add_header 'X-Content-Type-Options' 'nosniff';
  add_header 'X-XSS-Protection' '1; mode=block';

  location / {
    try_files $uri $uri/ /index.php?$args;
  }

  location /pub {
    location ~ ^/pub/media/(downloadable|customer|import|theme_customization/.*\.xml) {
      deny all;
    }
    alias $MAGE_ROOT/pub;
    add_header X-Frame-Options "SAMEORIGIN";
  }

  location /static/ {
    if ($MAGE_MODE = "production") {
      expires max;
    }

    location ~* \.(ico|jpg|jpeg|png|gif|svg|js|css|swf|eot|ttf|otf|woff|woff2)$ {
      add_header Cache-Control "public";
      add_header X-Frame-Options "SAMEORIGIN";
      expires +1y;

      if (!-f $request_filename) {
        rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
      }
    }

    location ~* \.(zip|gz|gzip|bz2|csv|xml)$ {
      add_header Cache-Control "no-store";
      add_header X-Frame-Options "SAMEORIGIN";
      expires off;

      if (!-f $request_filename) {
        rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
      }
    }

    if (!-f $request_filename) {
      rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
    }

    add_header X-Frame-Options "SAMEORIGIN";
  }

  location /media/ {
    try_files $uri $uri/ /get.php?$args;

    location ~ ^/media/theme_customization/.*\.xml {
      deny all;
    }

    location ~* \.(ico|jpg|jpeg|png|gif|svg|js|css|swf|eot|ttf|otf|woff|woff2)$ {
      add_header Cache-Control "public";
      add_header X-Frame-Options "SAMEORIGIN";
      expires +1y;
      try_files $uri $uri/ /get.php?$args;
    }

    location ~* \.(zip|gz|gzip|bz2|csv|xml)$ {
      add_header Cache-Control "no-store";
      add_header X-Frame-Options "SAMEORIGIN";
      expires off;
      try_files $uri $uri/ /get.php?$args;
    }

    add_header X-Frame-Options "SAMEORIGIN";
  }

  location /media/customer/ {
    deny all;
  }

  location /media/downloadable/ {
    deny all;
  }

  location /media/import/ {
    deny all;
  }

  location ~ /media/theme_customization/.*\.xml$ {
    deny all;
  }

  location /errors/ {
    try_files $uri =404;
  }

  location ~ ^/errors/.*\.(xml|phtml)$ {
    deny all;
  }

  location ~ cron\.php {
    deny all;
  }

  location ~ (index|get|static|report|404|503)\.php$ {
    try_files $uri =404;
    fastcgi_pass   127.0.0.1:9000;
    fastcgi_param  PHP_FLAG  "session.auto_start=off \n suhosin.session.cryptua=off";
    fastcgi_param  PHP_VALUE "memory_limit=768M \n max_execution_time=60";
    fastcgi_read_timeout 60s;
    fastcgi_connect_timeout 60s;
    fastcgi_param  MAGE_MODE $MAGE_MODE;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include    fastcgi_params;
  }

  # Default magento Nginx config finishes below

  client_max_body_size  20M;
}

If Nginx is new to you, let’s break down this file, as it also sheds light on Magento’s internal workings. The initial lines inform Nginx that we are using the default HTTP port and our domain is magento2.dev:

1
2
  listen 80;
  server_name magento2.dev;

Next, we set some environment variables. The first, $MAGE_ROOT, stores the path to our codebase. Remember to modify the root path to match your username and folder structure, wherever you intend to place the source code:

1
  set $MAGE_ROOT /Users/yourusername/www/magento2dev;

The second variable, $MAGE_MODE, sets the runtime mode for our store. Since we are developing a module, we’ll use developer mode, which speeds up development by eliminating the need to compile or deploy static files while coding. Other modes include production and default, although the latter’s practical use remains somewhat unclear.

1
  set $MAGE_MODE developer;

With these variables defined, we specify the vhost root path. Note that we append /pub to the $MAGE_ROOT variable, exposing only a portion of our store to the web.

1
  root $MAGE_ROOT/pub;

We then designate our index file, index.php, which Nginx loads when a requested file is not found and no index is present. This script, located at $MAGE_ROOT/pub/index.php, serves as the main entry point for both the shopping cart and admin applications. Regardless of the requested URL, index.php is loaded, initiating the router dispatching process.

1
  index index.php;

Next, we disable a couple of Nginx features. First, we turn off autoindex, which, if enabled, would display a file listing when a folder is requested without specifying a file and no index exists. Second, we disable charset, preventing Nginx from automatically adding Charset headers to the response.

1
2
  autoindex off;
  charset off;

We then define a few security headers:

1
2
  add_header 'X-Content-Type-Options' 'nosniff';
  add_header 'X-XSS-Protection' '1; mode=block';

This location, /, points to our root folder $MAGE_ROOT/pub and essentially redirects any incoming request to our front controller, index.php, along with the request arguments:

1
2
3
  location / {
    try_files $uri $uri/ /index.php?$args;
  }

The following section might seem a bit puzzling but is fairly straightforward. Earlier, we defined our root as $MAGE_ROOT/pub. This is the recommended and more secure configuration, as it shields most of the code from direct web access. However, it’s not the only way to set up the webserver. In fact, many shared web servers have a default configuration where the web server points to your web folder. For such scenarios, the Magento team has included this snippet:

1
2
3
4
5
6
7
location /pub {
    location ~ ^/pub/media/(downloadable|customer|import|theme_customization/.*\.xml) {
      deny all;
    }
    alias $MAGE_ROOT/pub;
    add_header X-Frame-Options "SAMEORIGIN";
  }

Keep in mind that, whenever feasible, it’s best practice to have your web server point to the $MAGE_ROOT/pub folder for enhanced security.

Moving on, we have the static location $MAGE_ROOT/pub/static. This folder, initially empty, is automatically populated with static files (images, CSS, JS, etc.) from modules and themes. Here, we define cache settings for static files and, if a requested file is not found, redirect the request to $MAGE_ROOT/pub/static.php. This script, among other tasks, analyzes the request and either copies or symlinks the requested file from the corresponding module or theme, depending on the defined runtime mode. This way, your module’s static files reside within the module folder but are served directly from the vhost public folder:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  location /static/ {
    if ($MAGE_MODE = "production") {
      expires max;
    }
location ~* \.(ico|jpg|jpeg|png|gif|svg|js|css|swf|eot|ttf|otf|woff|woff2)$ {
      add_header Cache-Control "public";
      add_header X-Frame-Options "SAMEORIGIN";
      expires +1y;

      if (!-f $request_filename) {
        rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
      }
    }

    location ~* \.(zip|gz|gzip|bz2|csv|xml)$ {
      add_header Cache-Control "no-store";
      add_header X-Frame-Options "SAMEORIGIN";
      expires off;

      if (!-f $request_filename) {
        rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
      }
    }

    if (!-f $request_filename) {
      rewrite ^/static/(version\d*/)?(.*)$ /static.php?resource=$2 last;
    }

    add_header X-Frame-Options "SAMEORIGIN";
  }

Next, we deny web access to certain sensitive folders and files:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
  location /media/customer/ {
    deny all;
  }

  location /media/downloadable/ {
    deny all;
  }

  location /media/import/ {
    deny all;
  }

  location ~ /media/theme_customization/.*\.xml$ {
    deny all;
  }

  location /errors/ {
    try_files $uri =404;
  }

  location ~ ^/errors/.*\.(xml|phtml)$ {
    deny all;
  }

  location ~ cron\.php {
    deny all;
  }

Finally, we load php-fpm and instruct it to execute index.php whenever a user makes a request to it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
location ~ (index|get|static|report|404|503)\.php$ {
    try_files $uri =404;
    fastcgi_pass   127.0.0.1:9000;
    fastcgi_param  PHP_FLAG  "session.auto_start=off \n suhosin.session.cryptua=off";
    fastcgi_param  PHP_VALUE "memory_limit=768M \n max_execution_time=60";
    fastcgi_read_timeout 60s;
    fastcgi_connect_timeout 60s;
    fastcgi_param  MAGE_MODE $MAGE_MODE;
    fastcgi_index  index.php;
    fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
    include    fastcgi_params;
  }

Save the file. To enable it, execute these commands:

1
2
3
ln -s /usr/local/etc/nginx/sites-available/magento2dev.conf \
 /usr/local/etc/nginx/sites-enabled/magento2dev.conf
sudo brew services restart nginx

Installing Magento 2

With your machine prepped and Magento 2 requirements met, it’s time to install Magento itself. Head over to the Magento website and create an account if you haven’t already. Then, visit the download page to download the latest version (2.1.5 at the time of writing):

Magento 2 download page

Choose the .tar.bz2 format and download the file. Extract it and set the appropriate folder and file permissions for Magento 2 to function correctly:

1
2
3
4
5
6
mkdir ~/www/magento2dev
cd ~/www/magento2dev
tar -xjf ~/Downloads/Magento-CE-2.1.5-2017-02-20-05-39-14.tar.bz2
find var vendor pub/static pub/media app/etc -type f -exec chmod u+w {} \;
find var vendor pub/static pub/media app/etc -type d -exec chmod u+w {} \;
chmod u+x bin/magento

Now, to install database tables and generate necessary configuration files, run the following command in your terminal:

1
2
3
4
5
6
./bin/magento setup:install --base-url=http://magento2.dev/ \
--db-host=127.0.0.1 --db-name=magento2 --db-user=root \
--db-password=123 --admin-firstname=Magento --admin-lastname=User \
--admin-email=user@example.com --admin-user=admin \
--admin-password=admin123 --language=en_US --currency=USD \
--timezone=America/Chicago --use-rewrites=1 --backend-frontname=admin

Ensure that the database name (db-name), username (db-user), and password (db-password) match the credentials you set during MySQL installation. This command installs all Magento 2 modules, creating the required tables and configuration files. Once complete, open your web browser and navigate to http://magento2.dev/. You should see a clean Magento 2 installation with the default Luma theme:

Home page in the default Luma theme

Similarly, visiting http://magento2.dev/admin should display the Admin application login page:

Admin application login page

Use these credentials to log in:

Username: admin Password: admin123

With that, we are finally ready to begin coding!

Creating Your First Magento 2 Module

Our module will consist of several files, which we’ll create step-by-step. These include:

  • Boilerplate registration files to introduce our Blog module to Magento
  • An interface file defining the data contract for a Post
  • A Post Model representing a blog post within our code, implementing the Post data interface
  • A Post Resource Model linking the Post Model to the database
  • A Post Collection to retrieve multiple posts from the database using the Resource Model
  • Two migration classes to set up our table schema and populate it with content
  • Two Actions: one to list all posts and another to display individual posts
  • Two sets of Blocks, Views, and Layout files: one set for the list action and another for the view action

Let’s start by examining the core source code folder structure to determine where to place our code. In our installation, all Magento 2 core code and its dependencies reside within the composer vendor folder.

Directory layout of Magento 2 core code

Registering the Module

We’ll keep our code separate, in the app/code folder. Every module follows the naming convention Namespace_ModuleName, and its file system location must reflect this: app/code/Namespace/ModuleName. Accordingly, we’ll name our module Toptal_Blog and store our files under app/code/Toptal/Blog. Create this folder structure.

Directory layout of our Toptal_Blog module

Now, we’ll create a few boilerplate files to register our module with Magento. First, create app/code/Toptal/Blog/composer.json:

1
{}

Composer reads this file every time it runs. While we won’t directly use Composer for our module, creating this file keeps Composer content.

Next, register the module with Magento. Create app/code/Toptal/Blog/registration.php:

1
2
3
4
5
6
7
<?php

\Magento\Framework\Component\ComponentRegistrar::register(
    \Magento\Framework\Component\ComponentRegistrar::MODULE,
    'Toptal_Blog',
    __DIR__
);

Here, we call the register method of the ComponentRegistrar class, passing two arguments: 'module', indicating the component type we are registering, and 'Toptal_Blog', our module’s name. This information enables Magento’s autoloader to recognize our namespace and locate our classes and XML files.

It’s worth noting that the component type (MODULE) is passed as an argument to \Magento\Framework\Component\ComponentRegistrar::register. We can register various component types, including themes, external libraries, and language packs, using this same method.

Continuing, create the final registration file, app/code/Toptal/Blog/etc/module.xml:

1
2
3
4
5
6
7
8
9
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="../../../../../lib/internal/Magento/Framework/Module/etc/module.xsd">
    <module name="Toptal_Blog" setup_version="0.1.0">
        <sequence>
            <module name="Magento_Directory" />
            <module name="Magento_Config" />
        </sequence>
    </module>
</config>

This file contains crucial information about our module:

  • The module name, exposing it to Magento’s configuration
  • The Magento setup version, which Magento uses to determine when to run database migration scripts
  • Our module’s dependencies. As we’re building a simple module, we depend only on two Magento core modules: Magento_Directory and Magento_Config.

Our module should now be recognizable by Magento 2. Let’s verify this using the Magento 2 CLI.

First, disable Magento’s cache. While Magento’s caching mechanisms deserve their own dedicated article, for now, since we are developing a module and need Magento to instantly recognize our changes without constantly clearing the cache, we’ll simply disable it. From your terminal, run:

1
./bin/magento cache:disable

Now, let’s check if Magento acknowledges our changes by viewing the module status:

1
./bin/magento module:status

The output should resemble:

Output of status command, showing Toptal_Blog module being disabled

Our module is listed but, as indicated, is still disabled. To enable it, run:

1
./bin/magento module:enable Toptal_Blog

This should do the trick. To confirm, you can execute module:status again and look for our module’s name in the enabled list:

Output of status command, showing Toptal_Blog module being enabled

Data Storage Handling

With our module enabled, let’s create the database table to store our blog posts. Here’s the desired table schema:

FieldTypeNullKeyDefault
post_idint(10) unsignedNOPRINULL
titletextNONULL
contenttextNONULL
created_attimestampNOCURRENT_TIMESTAMP

We’ll achieve this by creating the InstallSchema class, responsible for managing the installation of our schema migration. This file resides at app/code/Toptal/Blog/Setup/InstallSchema.php and contains the following code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
<?php

namespace Toptal\Blog\Setup;

use \Magento\Framework\Setup\InstallSchemaInterface;
use \Magento\Framework\Setup\ModuleContextInterface;
use \Magento\Framework\Setup\SchemaSetupInterface;
use \Magento\Framework\DB\Ddl\Table;

/**
 * Class InstallSchema
 *
 * @package Toptal\Blog\Setup
 */
class InstallSchema implements InstallSchemaInterface
{
    /**
     * Install Blog Posts table
     *
     * @param SchemaSetupInterface $setup
     * @param ModuleContextInterface $context
     */
    public function install(SchemaSetupInterface $setup, ModuleContextInterface $context)
    {
        $setup->startSetup();

        $tableName = $setup->getTable('toptal_blog_post');

        if ($setup->getConnection()->isTableExists($tableName) != true) {
            $table = $setup->getConnection()
                ->newTable($tableName)
                ->addColumn(
                    'post_id',
                    Table::TYPE_INTEGER,
                    null,
                    [
                        'identity' => true,
                        'unsigned' => true,
                        'nullable' => false,
                        'primary' => true
                    ],
                    'ID'
                )
                ->addColumn(
                    'title',
                    Table::TYPE_TEXT,
                    null,
                    ['nullable' => false],
                    'Title'
                )
                ->addColumn(
                    'content',
                    Table::TYPE_TEXT,
                    null,
                    ['nullable' => false],
                    'Content'
                )
                ->addColumn(
                    'created_at',
                    Table::TYPE_TIMESTAMP,
                    null,
                    ['nullable' => false, 'default' => Table::TIMESTAMP_INIT],
                    'Created At'
                )
                ->setComment('Toptal Blog - Posts');
            $setup->getConnection()->createTable($table);
        }

        $setup->endSetup();
    }
}

The install method simply creates our table and adds columns one by one.

Magento keeps track of schema migrations using the setup_module table, which stores the current setup versions for each module. Whenever a module’s version changes, its migration classes are initialized. Currently, this table doesn’t reference our module. Let’s change that. From your terminal, execute:

1
./bin/magento setup:upgrade

This displays a list of all modules and their executed migration scripts, including ours:

Output of upgrade command, showing our migration getting performed

Now, using your preferred MySQL client, you can verify that the table has been created:

Demonstration of our table in the MySQL client

Additionally, the setup_module table should now include a reference to our module, its schema, and data version:

Content of the setup_module table

Now, let’s address schema upgrades. We’ll add some posts to our table through an upgrade to demonstrate the process. First, increment the setup_version in our etc/module.xml file:

Highlight of the changed value in our module.xml file

Next, create the app/code/Toptal/Blog/Setup/UpgradeData.php file, responsible for data migrations (not schema changes):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php

namespace Toptal\Blog\Setup;

use \Magento\Framework\Setup\UpgradeDataInterface;
use \Magento\Framework\Setup\ModuleContextInterface;
use \Magento\Framework\Setup\ModuleDataSetupInterface;

/**
 * Class UpgradeData
 *
 * @package Toptal\Blog\Setup
 */
class UpgradeData implements UpgradeDataInterface
{

    /**
     * Creates sample blog posts
     *
     * @param ModuleDataSetupInterface $setup
     * @param ModuleContextInterface $context
     * @return void
     */
    public function upgrade(ModuleDataSetupInterface $setup, ModuleContextInterface $context)
    {
        $setup->startSetup();

        if ($context->getVersion()
            && version_compare($context->getVersion(), '0.1.1') < 0
        ) {
            $tableName = $setup->getTable('toptal_blog_post');

            $data = [
                [
                    'title' => 'Post 1 Title',
                    'content' => 'Content of the first post.',
                ],
                [
                    'title' => 'Post 2 Title',
                    'content' => 'Content of the second post.',
                ],
            ];

            $setup
                ->getConnection()
                ->insertMultiple($tableName, $data);
        }

        $setup->endSetup();
    }
}

This file resembles our InstallSchema class. The key difference is that it implements UpgradeDataInterface instead of InstallSchemaInterface, and the main method is named upgrade. This method checks the currently installed module version and, if it’s lower than the target version, applies the necessary changes. In our case, we use the version_compare function to check if the current version is less than 0.1.1:

1
2
3
        if ($context->getVersion()
            && version_compare($context->getVersion(), '0.1.1') < 0
        ) {

The $context->getVersion() call returns 0.1.0 when the setup:upgrade CLI command is executed for the first time. Sample data is then loaded into the database, and our version is bumped to 0.1.1. To run this upgrade, execute:

1
./bin/magento setup:upgrade

Check the results in the posts table:

Content of our table

And in the setup_module table:

Updated content of the setup_module table

While we added data to our table using the migration process, we could have modified the schema as well. The process remains the same; you would simply use the UpgradeSchemaInterface instead of UpgradeDataInterface.

Defining the Post Model

Moving up a layer, let’s implement our ViewModel and Controller. First, we’ll create the blog post ResourceModel. This simple model defines the table our Model will “connect” to and its primary key. Create the ResourceModel at app/code/Toptal/Blog/Model/ResourceModel/Post.php with the following content:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?php

namespace Toptal\Blog\Model\ResourceModel;

use \Magento\Framework\Model\ResourceModel\Db\AbstractDb;

class Post extends AbstractDb
{
    /**
     * Post Abstract Resource Constructor
     * @return void
     */
    protected function _construct()
    {
        $this->_init('toptal_blog_post', 'post_id');
    }
}

Unless you need custom CRUD operations, all ResourceModel operations are handled by the parent class, AbstractDb.

We also need another ResourceModel, a Collection, responsible for querying the database for multiple posts using our ResourceModel and returning a series of instantiated and populated Models. Create the file app/code/Toptal/Blog/Model/ResourceModel/Post/Collection.php:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
<?php
namespace Toptal\Blog\Model\ResourceModel\Post;

use \Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;

class Collection extends AbstractCollection
{
    /**
     * Remittance File Collection Constructor
     * @return void
     */
    protected function _construct()
    {
        $this->_init('Toptal\Blog\Model\Post', 'Toptal\Blog\Model\ResourceModel\Post');
    }
}

The constructor simply specifies the Model representing the post entity throughout our code and the ResourceModel responsible for database access.

The final piece in this layer is the Post Model itself. This model should contain all attributes defined in our schema, along with any necessary business logic. Following Magento 2’s conventions, we’ll create a Data Interface that our model will extend. This interface, located at app/code/Toptal/Blog/Api/Data/PostInterface.php, should hold the table’s field names and methods for accessing them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
<?php

namespace Toptal\Blog\Api\Data;

interface PostInterface
{
    /**#@+
     * Constants for keys of data array. Identical to the name of the getter in snake case
     */
    const POST_ID               = 'post_id';
    const TITLE                 = 'title';
    const CONTENT               = 'content';
    const CREATED_AT            = 'created_at';
    /**#@-*/


    /**
     * Get Title
     *
     * @return string|null
     */
    public function getTitle();

    /**
     * Get Content
     *
     * @return string|null
     */
    public function getContent();

    /**
     * Get Created At
     *
     * @return string|null
     */
    public function getCreatedAt();

    /**
     * Get ID
     *
     * @return int|null
     */
    public function getId();

    /**
     * Set Title
     *
     * @param string $title
     * @return $this
     */
    public function setTitle($title);

    /**
     * Set Content
     *
     * @param string $content
     * @return $this
     */
    public function setContent($content);

    /**
     * Set Crated At
     *
     * @param int $createdAt
     * @return $this
     */
    public function setCreatedAt($createdAt);

    /**
     * Set ID
     *
     * @param int $id
     * @return $this
     */
    public function setId($id);
}

Now, let’s implement the model at app/code/Toptal/Blog/Model/Post.php. We’ll create the methods defined in the interface, specify a cache tag using the CACHE_TAG constant, and, in the constructor, specify the ResourceModel responsible for database access.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
<?php

namespace Toptal\Blog\Model;

use \Magento\Framework\Model\AbstractModel;
use \Magento\Framework\DataObject\IdentityInterface;
use \Toptal\Blog\Api\Data\PostInterface;

/**
 * Class File
 * @package Toptal\Blog\Model
 * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
 */
class Post extends AbstractModel implements PostInterface, IdentityInterface
{
    /**
     * Cache tag
     */
    const CACHE_TAG = 'toptal_blog_post';

    /**
     * Post Initialization
     * @return void
     */
    protected function _construct()
    {
        $this->_init('Toptal\Blog\Model\ResourceModel\Post');
    }


    /**
     * Get Title
     *
     * @return string|null
     */
    public function getTitle()
    {
        return $this->getData(self::TITLE);
    }

    /**
     * Get Content
     *
     * @return string|null
     */
    public function getContent()
    {
        return $this->getData(self::CONTENT);
    }

    /**
     * Get Created At
     *
     * @return string|null
     */
    public function getCreatedAt()
    {
        return $this->getData(self::CREATED_AT);
    }

    /**
     * Get ID
     *
     * @return int|null
     */
    public function getId()
    {
        return $this->getData(self::POST_ID);
    }

    /**
     * Return identities
     * @return string[]
     */
    public function getIdentities()
    {
        return [self::CACHE_TAG . '_' . $this->getId()];
    }

    /**
     * Set Title
     *
     * @param string $title
     * @return $this
     */
    public function setTitle($title)
    {
        return $this->setData(self::TITLE, $title);
    }

    /**
     * Set Content
     *
     * @param string $content
     * @return $this
     */
    public function setContent($content)
    {
        return $this->setData(self::CONTENT, $content);
    }

    /**
     * Set Created At
     *
     * @param string $createdAt
     * @return $this
     */
    public function setCreatedAt($createdAt)
    {
        return $this->setData(self::CREATED_AT, $createdAt);
    }

    /**
     * Set ID
     *
     * @param int $id
     * @return $this
     */
    public function setId($id)
    {
        return $this->setData(self::POST_ID, $id);
    }
}

Creating Views

To define a front-end (shopping cart) application route, create the file app/code/Toptal/Blog/etc/frontend/routes.xml:

1
2
3
4
5
6
7
8
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:App/etc/routes.xsd">
    <router id="standard">
        <route id="blog" frontName="blog">
            <module name="Toptal_Blog"/>
        </route>
    </router>
</config>

Listing Posts on the Index Page

This tells Magento that our module, Toptal_Blog, handles requests to routes under http://magento2.dev/blog (note the frontName attribute of the route). Next, create the action at app/code/Toptal/Blog/Controller/Index/Index.php:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
<?php

namespace Toptal\Blog\Controller\Index;

use \Magento\Framework\App\Action\Action;
use \Magento\Framework\View\Result\PageFactory;
use \Magento\Framework\View\Result\Page;
use \Magento\Framework\App\Action\Context;
use \Magento\Framework\Exception\LocalizedException;

class Index extends Action
{

    /**
     * @var PageFactory
     */
    protected $resultPageFactory;

    /**
     * @param Context $context
     * @param PageFactory $resultPageFactory
     *
     * @codeCoverageIgnore
     * @SuppressWarnings(PHPMD.ExcessiveParameterList)
     */
    public function __construct(
        Context $context,
        PageFactory $resultPageFactory
    ) {
        parent::__construct(
            $context
        );
        $this->resultPageFactory = $resultPageFactory;
    }

    /**
     * Prints the blog from informed order id
     * @return Page
     * @throws LocalizedException
     */
    public function execute()
    {
        $resultPage = $this->resultPageFactory->create();
        return $resultPage;
    }
}

Our action defines two methods:

  • The constructor passes the $context parameter to its parent and sets the $resultPageFactory parameter as an attribute for later use. This utilizes the Dependency Injection design pattern In Magento 2, we have automatic dependency injection. During class instantiation, Magento attempts to instantiate all constructor parameters (dependencies) and inject them for you. It determines which classes to instantiate by examining the type hints, in this case, Context and PageFactory.

  • The execute method handles the action’s execution. Here, we simply instruct Magento to render the layout by returning a Magento\Framework\View\Result\Page object, which triggers the layout rendering process, which we’ll define shortly.

Navigating to the URL http://magento2.dev/blog/index/index should now display a blank page. We need to define the layout structure for this route, its corresponding Block (our ViewModel), and the template file to present the data to the user.

Front-end layout structures are defined under view/frontend/layout. The file name must reflect our route. Since our route is blog/index/index, the layout file will be app/code/Toptal/Blog/view/frontend/layout/blog_index_index.xml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="2columns-left" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="Toptal\Blog\Block\Posts"
                   name="posts.list"
                   template="Toptal_Blog::post/list.phtml" />
        </referenceContainer>
    </body>
</page>

Here, we define three crucial components of Magento’s layout structure: Blocks, Containers, and Templates.

  • Blocks represent the ViewModel in our MVVM architecture, acting as building blocks for our template structure.

  • Containers hold and output Blocks, organizing them hierarchically and providing structure during page layout processing.

  • Templates are PHMTL (PHP-embedded HTML) files used by a special type of Magento block. These templates can call methods of a $block variable, always available in the template context. This allows you to invoke Block methods, pulling information from the ViewModel layer into the presentation.

With this understanding, let’s analyze the XML layout structure above. It instructs Magento to, when a request is made to the blog/index/index route, add a Block of type Toptal\Blog\Block\Posts to the content container and use the template Toptal_blog::post/list.phtml for rendering.

This leads us to the creation of our two remaining files. First, the Block, located at app/code/Toptal/Blog/Block/Posts.php:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
<?php

namespace Toptal\Blog\Block;

use \Magento\Framework\View\Element\Template;
use \Magento\Framework\View\Element\Template\Context;
use \Toptal\Blog\Model\ResourceModel\Post\Collection as PostCollection;
use \Toptal\Blog\Model\ResourceModel\Post\CollectionFactory as PostCollectionFactory;
use \Toptal\Blog\Model\Post;

class Posts extends Template
{
    /**
     * CollectionFactory
     * @var null|CollectionFactory
     */
    protected $_postCollectionFactory = null;

    /**
     * Constructor
     *
     * @param Context $context
     * @param PostCollectionFactory $postCollectionFactory
     * @param array $data
     */
    public function __construct(
        Context $context,
        PostCollectionFactory $postCollectionFactory,
        array $data = []
    ) {
        $this->_postCollectionFactory = $postCollectionFactory;
        parent::__construct($context, $data);
    }

    /**
     * @return Post[]
     */
    public function getPosts()
    {
        /** @var PostCollection $postCollection */
        $postCollection = $this->_postCollectionFactory->create();
        $postCollection->addFieldToSelect('*')->load();
        return $postCollection->getItems();
    }

    /**
     * For a given post, returns its url
     * @param Post $post
     * @return string
     */
    public function getPostUrl(
        Post $post
    ) {
        return '/blog/post/view/id/' . $post->getId();
    }

}

This class is intentionally straightforward, responsible for loading the posts to be displayed and providing a getPostUrl method to the template. However, there are a few things to note.

We haven’t explicitly defined a Toptal\Blog\Model\ResourceModel\Post\CollectionFactory class, only Toptal\Blog\Model\ResourceModel\Post\Collection. How does this work? Magento 2 automatically creates a Factory for every class you define in your module. Factories have two methods: create, which returns a new instance on each call, and get, which always returns the same instance (used to implement the Singleton pattern).

The third parameter of our Block, $data, is an optional array. Being optional and lacking a type hint, it’s not automatically injected. Importantly, optional constructor parameters must always be positioned last. For example, the constructor of our parent class, Magento\Framework\View\Element\Template, has these parameters:

1
2
3
4
5
    public function __construct(
    Template\Context $context,
    array $data = []
  ) {
    ...

Since we wanted to add our CollectionFactory to the constructor parameters after extending the Template class, we had to do it before the optional parameter; otherwise, injection would fail:

1
2
3
4
5
6
       public function __construct(
        Context $context,
        PostCollectionFactory $postCollectionFactory,
        array $data = []
    ) {
       ...

The getPosts method, which our template will access later, simply calls the create method from PostCollectionFactory, retrieving a new PostCollection to fetch posts from the database and send them to the response.

Finally, create the PHTML template, app/code/Toptal/Blog/view/frontend/templates/post/list.phtml:

1
2
3
4
5
6
7
<?php /** @var Toptal\Blog\Block\Posts $block */ ?>
<h1>Toptal Posts</h1>
<?php foreach($block->getPosts() as $post): ?>
    <?php /** @var Toptal\Blog\Model\Post */ ?>
    <h2><a href="<?php echo $block->getPostUrl($post);?>"><?php echo $post->getTitle(); ?></a></h2>
    <p><?php echo $post->getContent(); ?></p>
<?php endforeach; ?>

Here, the View layer accesses our ModelView ($block->getPosts()), which, in turn, utilizes a ResourceModel (the collection) to retrieve our models (Toptal\Blog\Model\Post) from the database. Remember, the $block variable is always available in templates, allowing access to the corresponding block’s methods.

You should now see the post list by visiting our route again.

Our index page, showing the list of posts

Viewing Individual Posts

Clicking a post title currently leads to a 404 error. Let’s fix that. With our existing structure, this is quite simple. We’ll create:

  • A new action to handle requests to the blog/post/view route
  • A Block to render the post
  • A PHTML template for the view
  • A layout file for the blog/post/view route, connecting these components.

Our new action is straightforward, receiving the id parameter from the request and storing it in Magento’s core registry, a central repository for information accessible throughout a request cycle. This makes the ID available to the block later on. Create the file app/code/Toptal/Blog/Controller/Post/View.php:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
<?php

namespace Toptal\Blog\Controller\Post;

use \Magento\Framework\App\Action\Action;
use \Magento\Framework\View\Result\PageFactory;
use \Magento\Framework\View\Result\Page;
use \Magento\Framework\App\Action\Context;
use \Magento\Framework\Exception\LocalizedException;
use \Magento\Framework\Registry;

class View extends Action
{
    const REGISTRY_KEY_POST_ID = 'toptal_blog_post_id';

    /**
     * Core registry
     * @var Registry
     */
    protected $_coreRegistry;

    /**
     * @var PageFactory
     */
    protected $_resultPageFactory;

    /**
     * @param Context $context
     * @param Registry $coreRegistry
     * @param PageFactory $resultPageFactory
     *
     * @codeCoverageIgnore
     * @SuppressWarnings(PHPMD.ExcessiveParameterList)
     */
    public function __construct(
        Context $context,
        Registry $coreRegistry,
        PageFactory $resultPageFactory
    ) {
        parent::__construct(
            $context
        );
        $this->_coreRegistry = $coreRegistry;
        $this->_resultPageFactory = $resultPageFactory;
    }

    /**
     * Saves the blog id to the register and renders the page
     * @return Page
     * @throws LocalizedException
     */
    public function execute()
    {
        $this->_coreRegistry->register(self::REGISTRY_KEY_POST_ID, (int) $this->_request->getParam('id'));
        $resultPage = $this->_resultPageFactory->create();
        return $resultPage;
    }
}

We’ve added the $coreRegistry parameter to our __construct, saving it as an attribute. In the execute method, we retrieve the id parameter from the request and register it using a class constant, self::REGISTRY_KEY_POST_ID, as the registry key. We’ll use this same constant in our block to reference the ID.

Next, create the block at app/code/Toptal/Blog/Block/View.php:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
<?php

namespace Toptal\Blog\Block;

use \Magento\Framework\Exception\LocalizedException;
use \Magento\Framework\View\Element\Template;
use \Magento\Framework\View\Element\Template\Context;
use \Magento\Framework\Registry;
use \Toptal\Blog\Model\Post;
use \Toptal\Blog\Model\PostFactory;
use \Toptal\Blog\Controller\Post\View as ViewAction;

class View extends Template
{
    /**
     * Core registry
     * @var Registry
     */
    protected $_coreRegistry;

    /**
     * Post
     * @var null|Post
     */
    protected $_post = null;

    /**
     * PostFactory
     * @var null|PostFactory
     */
    protected $_postFactory = null;

    /**
     * Constructor
     * @param Context $context
     * @param Registry $coreRegistry
     * @param PostFactory $postCollectionFactory
     * @param array $data
     */
    public function __construct(
        Context $context,
        Registry $coreRegistry,
        PostFactory $postFactory,
        array $data = []
    ) {
        $this->_postFactory = $postFactory;
        $this->_coreRegistry = $coreRegistry;
        parent::__construct($context, $data);
    }

    /**
     * Lazy loads the requested post
     * @return Post
     * @throws LocalizedException
     */
    public function getPost()
    {
        if ($this->_post === null) {
            /** @var Post $post */
            $post = $this->_postFactory->create();
            $post->load($this->_getPostId());

            if (!$post->getId()) {
                throw new LocalizedException(__('Post not found'));
            }

            $this->_post = $post;
        }
        return $this->_post;
    }

    /**
     * Retrieves the post id from the registry
     * @return int
     */
    protected function _getPostId()
    {
        return (int) $this->_coreRegistry->registry(
            ViewAction::REGISTRY_KEY_POST_ID
        );
    }
}

The view block defines a protected method, _getPostId, which retrieves the post ID from the core registry. The public getPost method lazy-loads the post, throwing an exception if it doesn’t exist. While throwing an exception here might not be ideal (it displays Magento’s default error screen), we’ll keep it simple for this example.

Now, create the PHTML template, app/code/Toptal/Blog/view/frontend/templates/post/view.phtml:

1
2
3
<?php /** @var Toptal\Blog\Block\View $block */ ?>
<h1><?php echo $block->getPost()->getTitle(); ?></h1>
<p><?php echo $block->getPost()->getContent(); ?></p>

This template simply accesses the getPost method of our View block.

Finally, connect everything with a layout file for our new route at app/code/Toptal/Blog/view/frontend/layout/blog_post_view.xml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" layout="2columns-left" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
    <body>
        <referenceContainer name="content">
            <block class="Toptal\Blog\Block\View"
                   name="post.view"
                   template="Toptal_Blog::post/view.phtml" />
        </referenceContainer>
    </body>
</page>

Like before, this adds Toptal\Blog\Block\View to the content container, using Toptal_Blog::post/view.phtml as the template.

To see it in action, visit http://magento2.dev/blog/post/view/id/1 to load a post. You should see a screen similar to this:

Page for displaying individual posts

As you can see, once the initial structure is in place, adding features becomes quite simple, and we can reuse much of our existing code.

If you’d like to test the complete module, you can find the code here.

Continuing Your Magento 2 Development Journey

Congratulations on reaching this point! You’re well on your way to becoming a Magento 2 developer. This tutorial guided you through creating a relatively advanced Magento 2 custom module. While its features are simple, we covered a lot of ground.

For the sake of brevity, some aspects were omitted, including:

  • Admin edit forms and grids to manage blog content
  • Blog categories, tags, and comments
  • Repositories and service contracts
  • Packaging modules as Magento 2 extensions

To further your knowledge, explore these resources:

This tutorial equipped you with a solid foundation for Magento 2 module development, a starter code example, and additional resources for further exploration. Now it’s your turn to start coding or share your thoughts in the comments.

Licensed under CC BY-NC-SA 4.0