Magento 2
Master Magento 2 development and customization. Learn custom module creation, theme overrides, layout XML, plugins, and dependency injection best practices.

Introduction To Magento 2
- PART ONE: MAGENTO FOUNDATIONS & ENVIRONMENT
- Chapter 1: Introduction to E-Commerce & Magento
- Chapter 2: Setting Up Your Development Environment
- 2.1 Prerequisites — What You Must Install Before Magento
- 2.1.1 PHP 8.2 with Required Extensions
- 2.1.2 Composer – Dependency Management
- 2.1.3 MySQL – Database Fundamentals
- 2.1.4 OpenSearch (or Elasticsearch) — The Search Engine
- 2.1.5 Redis — Cache and Session Storage
- 2.1.6 RabbitMQ — Message Queue (Optional but Used by B2B)
- 2.1.7 Git — Version Control
- 2.1.8 Basic Web Concepts
- 2.1.9 Recommended Development Tools
- 2.2 Linux Setup — Complete Native Nginx + PHP-FPM + MySQL
- 2.3 Windows Setup — Five Methods
- 2.4 macOS Setup
- 2.5 Docker Setup (Cross-Platform)
- 2.6 Warden (Magento-specific Docker Orchestration)
- 2.7 DDEV
- 2.8 Magento PWA Studio Setup
- 2.9 IDE Configuration
- 2.10 Xdebug — Step Debugging
- 2.11 Post-Installation Configuration, File Permissions & Mode Setup (Applicable to All Environments)
- 2.12 Environment Setup: PhpStorm, Composer, MySQL Workbench, Postman
- 2.13 Installation Methods (Summary)
- 2.14 Troubleshooting Common Issues
- 2.15 Summary
- 2.1 Prerequisites — What You Must Install Before Magento
- Chapter 3: Magento Architecture & Internal Working
- PART TWO: BACKEND DEVELOPMENT & CORE SYSTEMS
- Chapter 4: PHP Foundations for Magento
- Chapter 5: Your First Magento Module — Hello World
- Chapter 6: Beginner Magento Concepts & Admin Panel
- Chapter 7: Core Extension Methods (Plugins, Preferences, Observers)
- Chapter 8: Intermediate Backend Development
- 8.1 Routing & Controllers (Frontend & Admin)
- 8.2 Blocks and Templates (PHTML)
- 8.3 UI Components for Admin Grids and Forms
- 8.4 Creating a Complete CRUD Module (Blog Module)
- 8.4.1 Module Registration (Quick Recap)
- 8.4.2 Database Schema (Declarative Schema)
- 8.4.3 Data Interface (Service Contract)
- 8.4.4 Model
- 8.4.5 Resource Model
- 8.4.6 Collection
- 8.4.7 Repository Interface
- 8.4.8 Repository Implementation
- 8.4.9 di.xml – Preferences
- 8.4.10 REST API (webapi.xml)
- 8.4.11 Admin Grid UI Component
- 8.4.12 Frontend Listing Page
- 8.5 Frontend Styling (LESS, CSS, SCSS) & Layout System Deep Dive
- 8.6 Magento Widgets, Catalog Management, Customer Management, Sales & Checkout Flow
- Chapter 9: Models, Resource Models, Collections, Blocks & Complete CRUD Module
- 9.1 Understanding the Magento Data Layer — Why Three Separate Classes?
- 9.2 The Model — Deep Explanation
- 9.3 The Resource Model — Deep Explanation
- 9.3.1 What a Resource Model Actually Is
- 9.3.2 What the Resource Model Is Responsible For
- 9.3.3 What the Resource Model Is NOT Responsible For
- 9.3.4 Anatomy of a Resource Model Class — Every Line Explained
- 9.3.5 What Happens Internally When _init() Is Called
- 9.3.6 Adding Custom Database Logic to a Resource Model
- 9.4 The Collection — Deep Explanation
- 9.5 The Block — Deep Explanation
- 9.6 Complete CRUD Module — Built From Scratch, Every File Explained
- 9.6.1 The Complete File Map
- 9.6.2 Module Registration Files
- 9.6.3 Database Schema — etc/db_schema.xml
- 9.6.4 The Data Interface (Service Contract) — Api/Data/StockItemInterface.php
- 9.6.5 The Model Implementing the Interface — Model/StockItem.php
- 9.6.6 The Resource Model — Model/ResourceModel/StockItem.php
- 9.6.7 The Collection — Model/ResourceModel/StockItem/Collection.php
- 9.6.8 The Repository Interface — Api/StockItemRepositoryInterface.php
- 9.6.9 The Search Results Interface — Api/Data/StockItemSearchResultsInterface.php
- 9.6.10 The Repository Implementation — Model/StockItemRepository.php
- 9.6.11 Dependency Injection Configuration — etc/di.xml
- 9.6.12 ACL Permissions — etc/acl.xml
- 9.6.13 Admin Routes — etc/adminhtml/routes.xml
- 9.6.14 Admin Menu — etc/adminhtml/menu.xml
- 9.6.15 Admin Controllers — One File Per Action
- 9.6.16 Admin UI Component — Grid (view/adminhtml/ui_component/mycompany_inventory_stockitem_listing.xml)
- 9.6.17 The UI Component Data Provider — Ui/DataProvider/StockItemDataProvider.php
- 9.6.18 The Actions Column Class — Ui/Component/Listing/Column/StockItemActions.php
- 9.6.19 Admin Form UI Component — view/adminhtml/ui_component/mycompany_inventory_stockitem_form.xml
- 9.6.20 The Save Button Block — Block/Adminhtml/StockItem/Edit/SaveButton.php
- 9.6.21 Layout for the Edit Page — view/adminhtml/layout/mycompany_inventory_stockitem_edit.xml
- 9.6.22 Frontend — Route, Controller, Layout, Block, Template
- 9.6.23 REST API — etc/webapi.xml
- 9.6.24 Deployment — Putting It All Together
- PART THREE: ADVANCED SYSTEMS & PERFORMANCE
- Chapter 10: Events, Observers & Plugins
- Chapter 11: Admin Development
- Chapter 12: Frontend Development
- Chapter 13: Commerce Systems
- Chapter 14: API Development & Integrations
- Chapter 15: Performance Engineering
- Chapter 16: Security Engineering
- PART FOUR: DEVOPS & ENTERPRISE
- PART FIVE: TESTING, CAREER & PROFESSIONAL DEVELOPMENT
PART ONE: MAGENTO FOUNDATIONS & ENVIRONMENT
Chapter 1: Introduction to E-Commerce & Magento
1.1 What is Magento?
Magento is an open-source, feature-rich e-commerce platform written in PHP, first released in 2008, designed specifically to give businesses a flexible, customizable foundation for building online stores. Unlike general-purpose web frameworks, Magento comes with commerce-specific functionality already built in – a product catalog, shopping cart, checkout process, order management, customer accounts, and an administrative panel – while remaining flexible enough that developers can heavily customize or completely replace almost any part.
Simple Understanding: Imagine you want to open a department store. Without a platform, you would have to build every shelf, cash register, security system, and inventory tracker from scratch. Magento is like a complete department store kit — all those components are pre-built and ready to be assembled. You only need to arrange them to match your brand, add your products, and start selling.
Key characteristics:
- Modular: every feature is a separate module that can be enabled, disabled, or replaced
- Extensible: you can change almost any behavior without modifying core code (using plugins, events, and preferences)
- API-first: REST and GraphQL APIs allow headless commerce, mobile apps, and third-party integrations
- Multi-store: manage multiple brands, languages, and currencies from a single installation
1.2 Types of Magento: Open Source vs. Adobe Commerce
| Feature | Magento Open Source | Adobe Commerce |
|---|---|---|
| Price | Free | Paid subscription |
| B2B features | No | Company accounts, requisition lists, negotiated quotes |
| Staging & preview | No | Scheduled updates, content staging |
| Advanced promotions | Basic | Customer segments, loyalty |
| Reporting | Basic | Advanced BI, dashboards |
| Support | Community | 24/7 Adobe support |
| Cloud deployment | Self-hosted | Adobe Commerce Cloud |
| Page Builder | No | Yes |
Which edition should you learn? Start with Open Source — it is free and contains 90% of the core concepts. Moving to Adobe Commerce later is straightforward because the architecture is identical; only extra modules are added.
1.3 History and Evolution of E-Commerce
E-commerce did not begin with the web. Its earliest form emerged in the 1960s and 1970s as Electronic Data Interchange (EDI) — a system through which large companies exchanged structured business documents such as purchase orders and invoices over private, leased networks.
The real transformation began in the early 1990s when the internet became publicly accessible. In 1994, the first secure online transaction took place — a CD was sold using early encryption technology. Amazon launched in 1995 as an online bookstore; eBay, also founded in 1995, created a consumer-to-consumer marketplace.
The mid-2000s witnessed the emergence of open-source commerce platforms, which democratized online selling. This movement directly led to Magento, released in 2008. The 2010s brought the mobile commerce (m-commerce) revolution.
The 2020s have been defined by:
- Headless and composable commerce — decoupling the frontend experience from the backend engine
- AI-driven personalization — machine learning for recommendations, dynamic pricing, and chatbots
- Social commerce — buying directly inside platforms like Instagram and TikTok
1.4 Commerce Models (B2B, B2C, D2C etc.)
Before writing a single line of Magento code, understand the different business models the platform must support:
- B2C (Business-to-Consumer) — a business sells directly to individual consumers. Examples: Amazon, typical clothing retailer websites. Characterized by single-unit purchasing, immediate payment, simple pricing. In Magento’s architecture, B2C is the “default” mode.
- B2B (Business-to-Business): A business model in which one company provides products or services to another company.Fundamentally differs from B2C:
- Negotiated, customer-specific pricing
- Allows multiple users to access the same account, with each user assigned different levels of permissions.
- Purchase orders and invoicing on net-30/60 terms
- Quote-based negotiation
- Bulk ordering interfaces
- D2C (Direct-to-Consumer) — a manufacturer sells directly to end consumers, bypassing intermediaries. Examples: Nike.com, Casper mattresses.
- Marketplace — the platform allows multiple independent sellers to list and sell products, taking a commission. Technical complexities include multi-vendor product management, split payments, and order splitting.
- Subscription Commerce — customers pay on a recurring basis. Requires recurring billing with tokenized payment credentials, subscription lifecycle management, and dunning management.
- Omnichannel Commerce — providing a unified, consistent shopping experience across all channels. Classic capabilities include Buy Online Pick Up In Store (BOPIS), ship-from-store, and unified customer profiles.
1.5 Real-World Use Cases and Industry Applications
| Industry | Example Brands | Why Magento? |
|---|---|---|
| Fashion & Apparel | Examples of well-known fashion and sports brands include Nike, Puma, and Hugo Boss. | Custom Product Configurators: Tools that allow customers to personalize products according to their preferences. Multi-Store: A setup that enables a business to manage multiple online stores from a single platform. |
| Electronics | Samsung, Canon | Thousands of SKUs, advanced faceted search |
| B2B Wholesale | Grainger, Wurth | Company accounts, requisition lists, tiered pricing |
| Luxury Goods | Bulgari, Montblanc | High-end imagery, custom checkout |
| Food & Beverage | Nestlé, Coca-Cola | Subscription boxes, recipe integration |
Case study: Puma uses Magento to run over 20 country-specific stores from a single admin panel. Each store has its own language, currency, and promotions. They integrated Magento with their warehouse system (ERP) using REST APIs, so inventory is updated in real time.
When to use Magento: Medium to large businesses (annual revenue >$1M), stores needing custom checkout or complex product rules, B2B companies needing quote negotiation, brands requiring multi-store or international expansion.
When NOT to use Magento: Very small stores with fewer than 100 products and simple needs (consider Shopify or WooCommerce), or if you lack a development team.
1.6 Modern Commerce Trends
Headless commerce is perhaps the most significant architectural shift of the past decade. In a traditional “monolithic” commerce platform, the same system that manages products, orders, and customers also renders the HTML pages. In a headless architecture, the commerce platform becomes purely a backend “engine” exposing data through APIs (REST or GraphQL), while the customer-facing experience is built as a completely separate application using modern frontend frameworks (React, Vue, Next.js).
Composable commerce extends headless further, assembling best-of-breed specialized services — sometimes called MACH architecture (Microservices, API-first, Cloud-native, Headless) — at the cost of significant integration complexity.
AI-driven personalization and search has moved from a “nice to have” to a competitive necessity. Modern commerce platforms increasingly embed machine learning directly into core functions.
Social commerce — selling directly through social media platforms — has blurred the line between content consumption and purchasing.
Chapter 2: Setting Up Your Development Environment
This section turns any computer into a professional Magento development environment. You will learn every command, every expected output, and every verification step. Check first, then install. Run the check command; only if the service is missing or outdated do you run the installation commands.
2.1 Prerequisites — What You Must Install Before Magento
Before you touch Magento, you need to master the following technologies. These are the seven supporting services you need before Magento itself.
2.1.1 PHP 8.2 with Required Extensions
Magento 2 is written in PHP and heavily uses Object‑Oriented Programming (OOP). You need PHP 8.2 (or 8.3, but 8.2 is the recommended version for Magento 2.4.7+). The following extensions are mandatory:
| Extension | Purpose |
|---|---|
| bcmath | Arbitrary‑precision math for price calculations where floating‑point rounding errors would be unacceptable. |
| curl | Outbound HTTP requests for payment gateways, shipping carrier APIs, third‑party integrations. |
| gd | Image processing for product photo resizing and watermarking. |
| intl | Internationalisation, currency symbols, number formatting for multi‑language stores. |
| mbstring | Correctly handles multi‑byte characters for non‑English product names. |
| pdo_mysql | The database driver (MySQL). |
| soap | Used by some legacy payment/shipping integrations and B2B services. |
| xml, xsl, zip | Essential for XML handling and compression. |
| opcache | Improves performance by caching compiled PHP scripts. |
You should also have a solid grasp of Object‑Oriented Programming concepts:
- Classes, objects, interfaces, traits, namespaces
- Type declarations: string, int, array, ?int, float, bool, object
- Constructor property promotion (PHP 8+)
- SOLID principles — especially Dependency Inversion
- Exception handling: try/catch, custom exceptions
Example of OOP in Magento context:
// A contract: any class implementing this MUST have getPrice() and getName()
interface ProductInterface
{
public function getPrice(): float;
public function getName(): string;
}
class SimpleProduct implements ProductInterface
{
public function __construct(
private string $name,
private float $price
) {}
public function getPrice(): float { return $this->price; }
public function getName(): string { return $this->name; }
}
$product = new SimpleProduct('T‑Shirt', 19.99);
echo $product->getName(); // T‑Shirt
Why this matters in Magento: the entire architecture is built on interfaces. When you type‑hint ProductInterface instead of a concrete class, Magento can swap the implementation for testing or customization without breaking your code.
Installation of PHP 8.2 on Each Operating System
Linux (Ubuntu/Debian)
Check if PHP is already installed:
php -v
If a version older than 8.2 appears, we will upgrade.
Update package list and install required tools:
sudo apt update
sudo apt install software-properties-common -y
Add the Repository PPA (which provides newer PHP versions):
sudo add-apt-repository ppa:ondrej/php -y
sudo apt update
Why this PPA? Ubuntu’s default repositories often only include one PHP version. The “ondrej/php” PPA is a widely trusted community‑maintained source providing multiple PHP versions side‑by‑side.
Install PHP 8.2 and all required extensions:
sudo apt install php8.2 php8.2-cli php8.2-fpm php8.2-mysql php8.2-xml \
php8.2-curl php8.2-mbstring php8.2-zip php8.2-gd php8.2-bcmath \
php8.2-intl php8.2-soap php8.2-xsl php8.2-opcache -y
Verify the installation:
php -v
# Should show PHP 8.2.x
php -m | grep bcmath
php -m | grep intl
php -m | grep curl
# Check all mandatory extensions are listed
If the wrong PHP version is still active, switch the default:
sudo update-alternatives --set php /usr/bin/php8.2
php -v
Check PHP-FPM status:
sudo systemctl status php8.2-fpm
If not running, start it: sudo systemctl start php8.2-fpm and enable it to start on boot: sudo systemctl enable php8.2-fpm.
Windows (Standalone)
Important: Choose the Thread Safe build if you plan to use Apache. The Non‑Thread‑Safe build will not work with Apache’s mod_php.
- Download PHP 8.2 from windows.php.net. Look for the latest 8.2.x version, e.g., php-8.2.20-Win32-vs16-x64.zip (Thread Safe).
- Extract the ZIP to
C:\php. Ensure thatC:\php\php.exeexists directly inside, not in a subfolder likeC:\php\php-8.2.20\. - Add PHP to the system PATH:
- Press Win + R, type
sysdm.cpl, and press Enter. - Go to the Advanced tab → Environment Variables.
- Under System variables, select Path and click Edit.
- Click New and type
C:\php. - Click OK on all windows.
- Configure
php.ini:
- In
C:\php, copyphp.ini-developmentand rename the copy tophp.ini. - Open
php.iniin Notepad (run as Administrator). - Uncomment (remove the leading
;) the following lines:extension=curl extension=fileinfo extension=gd extension=intl extension=mbstring extension=openssl extension=pdo_mysql extension=soap extension=zip - Add (if not present):
extension=bcmath - Set the timezone and memory limit (adjust to your region):
date.timezone = America/New_York memory_limit = 2G - Save the file.
- Verify:
- Open a new Command Prompt (to pick up the PATH changes).
- Run
php -v– you should see PHP 8.2. - Run
php -mand look for all required extensions.
Optional: If you want to use PHP-FPM on Windows (less common), you can install it as a service, but for XAMPP or WSL2 it’s easier.
macOS (Homebrew)
Check if PHP is already installed:
php -v
Install Homebrew (if not already installed):
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Install PHP 8.2 via Homebrew:
brew install php@8.2
Add PHP to your PATH so that the brew version is used:
Apple Silicon (M1/M2/M3):
echo 'export PATH="/opt/homebrew/opt/php@8.2/bin:$PATH"' >> ~/.zshrc
Intel Mac:
echo 'export PATH="/usr/local/opt/php@8.2/bin:$PATH"' >> ~/.zshrc
Then reload: source ~/.zshrc (or restart terminal).
Verify:
php -v
php -m | grep bcmath
php -m | grep intl
# Check all required extensions
Start PHP-FPM (Homebrew may have already started it, but ensure it runs):
brew services start php@8.2
2.1.2 Composer – Dependency Management
Composer is the PHP package manager. Magento itself is installed, updated, and extended through Composer.
Check if Composer exists:
composer --version
Install Composer:
Linux / macOS:
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer
sudo chmod +x /usr/local/bin/composer
Windows: Download and run Composer-Setup.exe from getcomposer.org. The installer will automatically add Composer to your PATH.
Verify:
composer --version
# Should output something like "Composer version 2.6.x"
If you see version 1.x, update: composer self-update --2.
Configure authentication for Magento repository (we’ll do this later when installing Magento).
Every Magento module has its own composer.json:
{
"name": "mycompany/module-blog",
"description": "Blog module for Magento 2",
"type": "magento2-module",
"require": {
"magento/framework": ">=103.0.0"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {
"MyCompany\\Blog\\": ""
}
}
}
Understanding Composer commands:
composer create-project— Creates a new project by using the files and configuration provided by an existing Composer package.composer require– adds a new package.composer update– updates all dependencies to latest versions.composer install– installs dependencies from composer.lock (used in deployment).composer outdated– shows which packages have newer versions.
2.1.3 MySQL – Database Fundamentals
Magento stores all data in a MySQL database. You need to know:
- CRUD operations (SELECT, INSERT, UPDATE, DELETE)
- JOINs (INNER, LEFT, RIGHT)
- Indexes (when to use them)
- Transactions (START TRANSACTION, COMMIT, ROLLBACK)
- Foreign keys and referential integrity
- Query optimization using EXPLAIN
Example of a typical Magento reporting query:
SELECT o.increment_id, c.firstname, c.lastname, o.grand_total
FROM sales_order o
INNER JOIN customer_entity c ON o.customer_id = c.entity_id
WHERE o.created_at > '2024-01-01'
ORDER BY o.created_at DESC
LIMIT 10;
Magento’s database uses a mix of normalized tables and EAV (Entity‑Attribute‑Value) for flexible attributes. Understanding EAV is crucial for writing efficient queries.
Why the database matters: Every product, customer, order, and configuration setting is stored as rows in MySQL tables — hundreds of tables, in fact. Magento’s installer creates roughly 500+ tables during a fresh install.
Installation of MySQL on Each OS
Linux (Ubuntu/Debian)
Check if MySQL is installed:
mysql --version
Install MySQL Server:
sudo apt install mysql-server -y
Start and enable MySQL:
sudo systemctl start mysql
sudo systemctl enable mysql
systemctl enable ensures MySQL starts automatically on system boot.
Secure the installation:
sudo mysql_secure_installation
- Set a root password (remember it).
- Remove anonymous users – type Y.
- Disallow remote root login – type Y.
- Remove test database – type Y.
- Reload privilege tables – type Y.
Verify MySQL is running:
sudo systemctl status mysql
# Active: active (running)
Windows
- Download the MySQL Installer from dev.mysql.com.
- Run the installer and choose Developer Default (which includes MySQL Server, Workbench, etc.).
- During setup:
- Choose Server only or Developer Default (the latter includes tools).
- Set a root password (remember it).
- Leave Start the MySQL Server at System Startup checked.
- After installation, verify by opening Command Prompt and running:
mysql -u root -p
(Enter the password you set.)
macOS (Homebrew)
Install MySQL:
brew install mysql
Start MySQL as a service:
brew services start mysql
Secure the installation:
mysql_secure_installation
Follow the same steps as Linux.
Verify:
mysql -u root -p
Create the Magento Database and User (All OS)
After MySQL is running, log in as root:
mysql -u root -p
Enter the root password, then execute the following SQL statements:
CREATE DATABASE magento;
CREATE USER 'magento_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
GRANT ALL PRIVILEGES ON magento.* TO 'magento_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Explanation:
CREATE DATABASE magento;– creates an empty database container.CREATE USER ...– creates a new MySQL login that is only allowed to connect from the same machine (@'localhost').GRANT ALL PRIVILEGES ON magento.*– gives this user full permission only on the magento database, not any other database on the server.FLUSH PRIVILEGES– tells MySQL to immediately apply the permission changes.
Verify the new user can connect:
mysql -u magento_user -pStrongPassword123! -e "SHOW DATABASES;"
(Note: no space between -p and the password.)
If you see Access denied, re‑check your CREATE USER and GRANT steps.
2.1.4 OpenSearch (or Elasticsearch) — The Search Engine
Starting with Magento 2.4, the installer will refuse to complete without a working OpenSearch (or Elasticsearch) connection. We recommend using Docker for simplicity.
Check if OpenSearch is already running:
curl -X GET "localhost:9200"
If the application returns a JSON response, it indicates that the application is running successfully.Otherwise, proceed.
Install Docker first (if not already installed):
- Linux: Install Docker, enable and start its service, and add the current user to the Docker group with the following command. Afterward, log out and sign in again for the changes to take effect.
- Windows/macOS: Download and install Docker Desktop from the official Docker website.
Run the OpenSearch container:
docker run -d --name opensearch -p 9200:9200 -p 9600:9600 \
-e "discovery.type=single-node" \
-e "DISABLE_SECURITY_PLUGIN=true" \
opensearchproject/opensearch:2.5.0
Breaking down the command:
-d– runs container in background.--name opensearch– assigns a friendly name.-p 9200:9200 -p 9600:9600– maps container ports to host.discovery.type=single-node– prevents clustering (for local dev).DISABLE_SECURITY_PLUGIN=true– disables authentication for simplicity.
Verify (wait 30 seconds for it to start):
curl -X GET "localhost:9200"
Expected output: JSON with "tagline" : "The OpenSearch Project: ...".
If you get a conflict (container already exists), remove it:
docker rm -f opensearch
Then re‑run the docker run command.
Alternative: If you prefer Elasticsearch, replace the image with docker.elastic.co/elasticsearch/elasticsearch:7.17.0 and adjust environment variables accordingly.
2.1.5 Redis — Cache and Session Storage
Redis stores sessions (cart data) in super‑fast memory and caches pre‑computed results of expensive operations. It is strongly recommended for production, but optional for development.
Run Redis container:
docker run -d --name redis -p 6379:6379 redis:7
Verify:
docker exec -it redis redis-cli ping
Expected output: PONG.
2.1.6 RabbitMQ — Message Queue (Optional but Used by B2B)
RabbitMQ offloads heavy operations (sending emails, processing bulk product updates) to background processes. It is used by Magento’s B2B features and some third‑party modules.
Run RabbitMQ container:
docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 rabbitmq:3-management
The -management tag includes a web dashboard.
Verify: Open http://localhost:15672 in a browser, log in with guest/guest.
2.1.7 Git — Version Control
Check if Git is installed:
git --version
Install Git if needed:
- Linux (Ubuntu/Debian):
sudo apt install git -y - macOS:
brew install git - Windows: Download from git-scm.com. During installation, choose “Git from the command line and also from 3rd‑party software”.
Configure your identity:
git config --global user.name "Your Full Name"
git config --global user.email "your.email@example.com"
Basic Git workflow:
git clone git@github.com:mycompany/magento-store.git
git checkout -b feature/blog-module
git add .
git commit -m "Add blog module with CRUD"
git push origin feature/blog-module
# Then open a pull request
2.1.8 Basic Web Concepts
You should understand how the web works. Here’s a quick recap:
| Concept | Description |
|---|---|
| HTTP methods | GET, POST, PUT, DELETE |
| Status codes | 200 (OK), 301 (redirect), 404 (Not Found), 500 (Internal Server Error) |
| Cookies and sessions | How Magento tracks shopping carts and customer logins |
| REST APIs | Endpoints, request/response structure, authentication (bearer tokens) |
| Caching | What it is and why it matters (Full Page Cache, Redis, Varnish) |
| DNS and hosts files | How domain names resolve to IP addresses; the hosts file overrides DNS locally. |
2.1.9 Recommended Development Tools
| Tool | Purpose |
|---|---|
| PhpStorm | IDE with Magento plugin (recommended) |
| VS Code | Lightweight alternative with PHP extensions |
| MySQL Workbench | Visual database management |
| Postman | Test REST APIs |
| Docker | Containerized environment (cross‑platform) |
| Xdebug | Step‑by‑step debugging |
2.2 Linux Setup — Complete Native Nginx + PHP-FPM + MySQL
This section describes setting up Magento on a native Linux (Ubuntu/Debian) system using Nginx, PHP‑FPM, MySQL, OpenSearch, Redis, and optionally RabbitMQ. This is the recommended approach for production‑like development.
The big picture: Nginx is the “front door” that receives browser requests; PHP‑FPM is the “worker” that Nginx hands PHP files to for execution; Magento is the application that PHP‑FPM runs, which in turn talks to MySQL, OpenSearch, Redis, and RabbitMQ.
2.2.1 Update System and Install Nginx
Update package index and upgrade existing packages:
sudo apt update && sudo apt upgrade -y
Install Nginx:
sudo apt install nginx -y
Start Nginx and enable it to start on boot:
sudo systemctl start nginx
sudo systemctl enable nginx
Verify Nginx is running:
sudo systemctl status nginx
Look for Active: active (running).
Test in browser: Open http://localhost – you should see the “Welcome to nginx” page. If the page doesn’t load:
- Check firewall:
sudo ufw status. If active and port 80 is not allowed, runsudo ufw allow 80/tcp. - Check whether port 80 is already in use with
sudo ss -tlnp | grep :80. If another service is occupying the port, stop that service or configure it to use a different port.
2.2.2 Start PHP-FPM
Ensure PHP-FPM is installed (we already installed it in 2.1.1). If not, install it:
sudo apt install php8.2-fpm -y
Start and enable PHP-FPM:
sudo systemctl start php8.2-fpm
sudo systemctl enable php8.2-fpm
Verify PHP-FPM is running:
sudo systemctl status php8.2-fpm
Check that the socket file exists:
ls -la /run/php/php8.2-fpm.sock
You should see a file with srw-rw---- permissions. The first character s indicates a socket.
If the socket doesn’t exist, check the PHP-FPM configuration: /etc/php/8.2/fpm/pool.d/www.conf – the listen directive should point to /run/php/php8.2-fpm.sock.
2.2.3 Magento Installation (Step-by-Step)
Step 1 – Obtain Magento Marketplace credentials
- Create a free account at https://marketplace.magento.com.
- Log in, go to My Profile → Access Keys.
- Click Create A New Access Key.
- Copy the Public Key and Private Key (they look like long alphanumeric strings).
Step 2 – Configure Composer with these credentials globally
composer global config http-basic.repo.magento.com <public-key> <private-key>
This creates or updates ~/.composer/auth.json. You can verify:
cat ~/.composer/auth.json
Step 3 – Create the Magento project
We’ll install Magento in /var/www/magento.
cd /var/www
sudo composer create-project --repository-url=https://repo.magento.com/ \
magento/project-community-edition magento
Note: We use sudo because /var/www is owned by root. This may prompt for your password. The installation takes several minutes and produces many lines like Installing magento/module-catalog (103.0.0). Wait until it finishes.
If it fails with 401 Unauthorized: re‑check your access keys and the global config.
If it appears to hang: This is normal – Composer is resolving dependencies. If more than 15 minutes pass with no output, press Ctrl+C and re‑run (sometimes the network is slow).
Step 4 – Set correct file permissions
After the project is created, you must set ownership and permissions so that the web server can read and write files.
cd /var/www/magento
sudo chown -R www-data:www-data .
sudo find var generated pub/static pub/media app/etc -type f -exec chmod g+w {} +
sudo find var generated pub/static pub/media app/etc -type d -exec chmod g+ws {} +
sudo chmod u+x bin/magento
Explanation:
chown -R www-data:www-data .– makes the web server user (www-data) the owner of all files.- The two
findcommands add group write permissions (g+w) to files and directories that Magento needs to write to at runtime (cache, logs, media, static files). Theg+wssets the setgid bit so new files inherit the group. chmod u+x bin/magento– makes the Magento CLI executable.
Step 5 – Run the Magento installer
Now we execute the installation command. We’ll use sudo -u www-data to run it as the web server user, ensuring that any files created during installation have the correct ownership.
sudo -u www-data bin/magento setup:install \
--base-url=http://magento.local/ \
--db-host=localhost \
--db-name=magento \
--db-user=magento_user \
--db-password=StrongPassword123! \
--admin-firstname=Admin \
--admin-lastname=User \
--admin-email=admin@example.com \
--admin-user=admin \
--admin-password=Admin123! \
--language=en_US \
--currency=USD \
--timezone=America/New_York \
--use-rewrites=1 \
--search-engine=opensearch \
--opensearch-host=localhost \
--opensearch-port=9200 \
--cache-backend=redis \
--cache-backend-redis-server=127.0.0.1 \
--page-cache=redis \
--page-cache-redis-server=127.0.0.1 \
--session-save=redis \
--session-save-redis-host=127.0.0.1
Important parameters explained:
--base-url– the URL where your store will be accessible. We’ll set up a virtual host later.--db-*– database connection details.--admin-*– administrator account details (remember them).--search-engine– we use OpenSearch.--cache-backend,--page-cache,--session-save– we use Redis for all caching and sessions (optional but recommended).
The installer shows progress like [Progress: 1/850] ... and eventually outputs:
[SUCCESS]: Magento installation complete.
[SUCCESS]: Admin URI: /admin_1a2b3c
Write down the admin URI – you will need it to log in.
Step 6 – Configure Nginx virtual host
We need to tell Nginx how to serve this Magento installation.
Create a new configuration file:
sudo nano /etc/nginx/sites-available/magento.conf
Paste the following content:
upstream fastcgi_backend {
server unix:/run/php/php8.2-fpm.sock;
}
server {
listen 80;
server_name magento.local;
set $MAGE_ROOT /var/www/magento;
include /var/www/magento/nginx.conf.sample;
}
Explanation:
upstreamdefines the PHP‑FPM backend (using the socket).server_name– we’ll usemagento.localas the domain.$MAGE_ROOT– points to the Magento root.- The
includedirective imports Magento’s own Nginx configuration, which handles rewrites, static files, etc.
Save and exit (Ctrl+O, Ctrl+X).
Enable the site by creating a symbolic link:
sudo ln -s /etc/nginx/sites-available/magento.conf /etc/nginx/sites-enabled/
Test the configuration:
sudo nginx -t
If syntax is OK, reload Nginx:
sudo systemctl reload nginx
Step 7 – Update hosts file
Since we used a custom domain magento.local, we need to tell our computer to resolve it to 127.0.0.1.
echo "127.0.0.1 magento.local" | sudo tee -a /etc/hosts
Step 8 – Verify the installation
- Open your browser and go to
http://magento.local– you should see the Luma storefront (Magento’s default theme). - Go to the admin URL (e.g.,
http://magento.local/admin_1a2b3c) and log in withadmin/Admin123!.
If you encounter a 404 or 500 error, review the following:
- The Nginx error log:
sudo tail -f /var/log/nginx/error.log - The Magento system log:
tail -f /var/www/magento/var/log/system.log - File permissions: ensure
varandpub/staticare writable.
2.2.4 Linux – Apache Setup (Alternative)
If you prefer Apache instead of Nginx, follow these steps.
Install Apache:
sudo apt install apache2 -y
sudo systemctl enable apache2
sudo systemctl start apache2
Enable mod_rewrite:
sudo a2enmod rewrite
Create a virtual host configuration in /etc/apache2/sites-available/magento.conf:
<VirtualHost *:80>
ServerName magento.local
DocumentRoot /var/www/magento/pub
<Directory /var/www/magento/pub>
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Enable the site:
sudo a2ensite magento.conf
sudo systemctl reload apache2
The rest (database, permissions, installation) is the same as the Nginx section. Remember to update --base-url accordingly.
2.2.5 Installing Magento with Sample Data (Optional)
Sample data provides a demo store with products, categories, and customers. It’s useful for learning and development.
Method 1: Using CLI after installation
cd /var/www/magento
php bin/magento sampledata:deploy
php bin/magento setup:upgrade
php bin/magento cache:flush
Method 2: During initial installation with Composer
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento2 --no-install
cd magento2
composer config repositories.magento composer https://repo.magento.com/
composer require magento/sample-data:*
composer install
php bin/magento setup:install ... (regular installation command)
2.3 Windows Setup — Five Methods
Windows users have several options. We’ll cover:
- WSL2 (Recommended) – runs a full Linux environment inside Windows.
- XAMPP – all‑in‑one Apache, MySQL, PHP package.
- Chocolatey + Manual – using package manager and manual configuration.
- Docker Desktop – containerized setup (covered in 2.6).
- Warden/DDEV – orchestration tools (covered later).
Important for all native Windows methods: Always use --db-host=127.0.0.1 instead of localhost in the Magento installer. On Windows, resolving localhost can involve extra steps through Windows’ name‑resolution system, adding noticeable delay to every database query.
2.3.1 WSL2 (Recommended)
WSL2 (Windows Subsystem for Linux) gives you a genuine Ubuntu kernel running on Windows. It is the smoothest way to develop Magento on Windows because all Linux commands work exactly as described in Section 2.2.
Step 1 – Install WSL2
Open PowerShell as Administrator and run:
wsl --install
This will install Ubuntu by default. Restart your computer when prompted.
Step 2 – Set up Ubuntu
After restart, launch the “Ubuntu” app from the Start menu.You will be prompted to create a UNIX username and password during the setup process.(Note: nothing appears on screen while typing the password – this is normal.)
Step 3 – Update Ubuntu and Install Required Dependencies
Inside the Ubuntu terminal, follow Section 2.2 exactly. All commands (apt, systemctl, etc.) work identically. You can install Nginx, PHP, MySQL, OpenSearch, Redis, etc., using the same steps.
Performance tip: Place your Magento project files inside the Linux filesystem (e.g., /var/www/magento) – not under /mnt/c/.... Files accessed across the Windows/Linux boundary are dramatically slower.
Access from Windows:
- Open File Explorer and navigate to
\\wsl$\Ubuntu\var\www\magentoto view the files. - To edit files, you can use Windows editors (like VS Code) – they will open via the
\\wsl$path.
Configure Windows hosts file:
Open Notepad as Administrator, then open C:\Windows\System32\drivers\etc\hosts. Add:
127.0.0.1 magento.local
Save. Now you can access http://magento.local from your Windows browser.
2.3.2 XAMPP (Direct Setup)
XAMPP provides Apache, MySQL, PHP and other tools in one package. It’s easy but less flexible than WSL2.
Step 1 – Download and install XAMPP
Download the version with PHP 8.2 from apachefriends.org. Run the installer and install to C:\xampp.
Step 2 – Start Apache and MySQL
Open the XAMPP Control Panel. Click Start for Apache and MySQL. Both rows should turn green.
Step 3 – Install Magento via Composer
Open Command Prompt (you can run as Administrator if needed) and navigate to the htdocs folder:
cd C:\xampp\htdocs
Create the Magento project (use your Composer credentials):
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento
Step 4 – Set file permissions (Windows ACLs)
Run Command Prompt as Administrator, then:
icacls C:\xampp\htdocs\magento\var /grant Everyone:F /T
icacls C:\xampp\htdocs\magento\generated /grant Everyone:F /T
icacls C:\xampp\htdocs\magento\pub\static /grant Everyone:F /T
icacls C:\xampp\htdocs\magento\pub\media /grant Everyone:F /T
(These give full control to Everyone – not secure for production, but fine for local development.)
Step 5 – Create MySQL database
Open Command Prompt and connect to MySQL:
C:\xampp\mysql\bin\mysql.exe -u root -p
(There’s no password by default – just press Enter.)
Then run the SQL commands from 2.1.3 to create the database and user:
CREATE DATABASE magento;
CREATE USER 'magento_user'@'localhost' IDENTIFIED BY 'StrongPassword123!';
GRANT ALL PRIVILEGES ON magento.* TO 'magento_user'@'localhost';
FLUSH PRIVILEGES;
EXIT;
Step 6 – Configure Apache virtual host
Open C:\xampp\apache\conf\extra\httpd-vhosts.conf in Notepad (run as Administrator). Add:
<VirtualHost *:80>
ServerName magento.local
DocumentRoot "C:/xampp/htdocs/magento/pub"
<Directory "C:/xampp/htdocs/magento/pub">
Options Indexes FollowSymLinks
AllowOverride All
Require all granted
</Directory>
</VirtualHost>
Also, edit C:\xampp\apache\conf\httpd.conf and uncomment the line:
#Include conf/extra/httpd-vhosts.conf
(remove the #).
Step 7 – Update Windows hosts file
Open Notepad as Administrator, then C:\Windows\System32\drivers\etc\hosts. Add:
127.0.0.1 magento.local
Step 8 – Run Magento installation
Navigate to your Magento folder:
cd C:\xampp\htdocs\magento
Run the installer (note the caret ^ is line continuation; there must be no trailing space after each ^):
php bin\magento setup:install ^
--base-url=http://magento.local/ ^
--db-host=127.0.0.1 ^
--db-name=magento --db-user=magento_user --db-password=StrongPassword123! ^
--admin-firstname=Admin --admin-lastname=User --admin-email=admin@example.com ^
--admin-user=admin --admin-password=Admin123! ^
--language=en_US --currency=USD --timezone=America/New_York --use-rewrites=1 ^
--search-engine=opensearch --opensearch-host=127.0.0.1 --opensearch-port=9200 ^
--cache-backend=redis --cache-backend-redis-server=127.0.0.1 ^
--page-cache=redis --page-cache-redis-server=127.0.0.1 ^
--session-save=redis --session-save-redis-host=127.0.0.1
Note: we use 127.0.0.1 for database and services.
Step 9 – Restart Apache
From the XAMPP Control Panel, click Stop then Start for Apache.
Step 10 – Verify
Open http://magento.local in your browser. You should see the Luma store.
2.3.3 Chocolatey + Manual Setup (Advanced)
If you prefer a more lightweight approach without XAMPP, you can use Chocolatey to install PHP, Composer, and MySQL, then configure Nginx or Apache manually. This is similar to XAMPP but gives more control.
Install Chocolatey (run PowerShell as Administrator):
Set-ExecutionPolicy Bypass -Scope Process -Force
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
Install PHP 8.2, Composer, MySQL, and Nginx:
choco install php --version=8.2.0
choco install composer
choco install mysql
choco install nginx
Configure PHP, MySQL, and Nginx similarly to Linux (adjust paths). This is more involved and not covered in full detail here – consider it an advanced option.
2.4 macOS Setup
macOS users have two primary methods: Homebrew (native) and MAMP (all‑in‑one). We’ll detail both.
2.4.1 Homebrew (Nginx + PHP-FPM + MySQL)
This approach gives you a native environment similar to Linux.
Step 1 – Install Homebrew (if not already)
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
Step 2 – Install services
brew install nginx
brew install php@8.2
brew install mysql
brew install composer
brew install redis # optional
brew install opensearch # or use Docker
Step 3 – Start services
brew services start nginx
brew services start php@8.2
brew services start mysql
brew services start redis
# For OpenSearch, use Docker as described earlier
Note: By default, Nginx on macOS listens on port 8080 (to avoid conflict with built‑in Apache). You’ll need to adjust your base URL accordingly.
Step 4 – Create Sites directory and install Magento
mkdir -p ~/Sites
cd ~/Sites
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento
cd magento
Step 5 – Set permissions (simpler on macOS)
Since PHP‑FPM runs as your user, permissions are simpler:
chmod -R 775 var pub/static pub/media generated
Step 6 – Create MySQL database (same as Linux)
mysql -u root -p
# then run CREATE DATABASE, CREATE USER, GRANT...
Step 7 – Configure Nginx
The Nginx configuration file is located at:
- Apple Silicon:
/opt/homebrew/etc/nginx/nginx.conf - Intel:
/usr/local/etc/nginx/nginx.conf
Add a server block inside the http block or in a separate include file.
Example block (add to nginx.conf or create a new file in servers/):
server {
listen 8080;
server_name magento.local;
set $MAGE_ROOT /Users/yourusername/Sites/magento;
include /Users/yourusername/Sites/magento/nginx.conf.sample;
}
Reload Nginx: brew services restart nginx.
Step 8 – Update hosts file
sudo nano /etc/hosts
Add: 127.0.0.1 magento.local
Step 9 – Run Magento installer
php bin/magento setup:install \
--base-url=http://magento.local:8080/ \
--db-host=localhost \
--db-name=magento --db-user=magento_user --db-password=StrongPassword123! \
--admin-firstname=Admin --admin-lastname=User --admin-email=admin@example.com \
--admin-user=admin --admin-password=Admin123! \
--language=en_US --currency=USD --timezone=America/New_York --use-rewrites=1 \
--search-engine=opensearch --opensearch-host=localhost --opensearch-port=9200 \
--cache-backend=redis --cache-backend-redis-server=127.0.0.1 \
--page-cache=redis --page-cache-redis-server=127.0.0.1 \
--session-save=redis --session-save-redis-host=127.0.0.1
Note the :8080 in base URL.
Step 10 – Verify
Open http://magento.local:8080 in your browser.
2.4.2 MAMP
MAMP (Mac Apache MySQL PHP) is an all‑in‑one solution.
- Download and install MAMP from mamp.info.
- Launch MAMP, go to Preferences → PHP and select PHP 8.2.
- Click Start Servers.
- By default, the web root is
/Applications/MAMP/htdocs. - Install Magento there:
cd /Applications/MAMP/htdocs
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento
- Create database in phpMyAdmin (accessible via
http://localhost:8888/phpmyadmin) or via command line (MAMP’s MySQL uses port 8889). - Run installer with
--db-host=127.0.0.1 --db-port=8889 --base-url=http://localhost:8888/and similar adjustments. - Set permissions:
chmod -R 775 ...
2.5 Docker Setup (Cross-Platform)
Docker is “self‑contained” — each piece of software runs inside an isolated container. Deleting a container removes everything with zero leftover traces on your computer. This is the most portable method.
2.5.1 Install Docker
- Windows/macOS: Obtain Docker Desktop from the official Docker website and complete the installation process.
- Linux (Ubuntu):
sudo apt install docker.io docker-compose -y
sudo systemctl enable --now docker
sudo usermod -aG docker $USER # log out and back in
2.5.2 Create docker-compose.yml
Create a project directory, e.g., ~/magento-docker, and inside it create docker-compose.yml with the following content:
version: '3'
services:
db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento
MYSQL_USER: magento_user
MYSQL_PASSWORD: StrongPassword123!
ports: ["3306:3306"]
volumes: [db-data:/var/lib/mysql]
redis:
image: redis:7
ports: ["6379:6379"]
opensearch:
image: opensearchproject/opensearch:2.5.0
environment:
- discovery.type=single-node
- DISABLE_SECURITY_PLUGIN=true
ports: ["9200:9200"]
php:
image: php:8.2-fpm
volumes: [./magento:/var/www/magento]
depends_on: [db, redis, opensearch]
nginx:
image: nginx:latest
ports: ["80:80"]
volumes:
- ./magento:/var/www/magento
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on: [php]
volumes:
db-data:
You also need a simple nginx.conf file (in the same directory) for the Nginx container:
server {
listen 80;
server_name localhost;
set $MAGE_ROOT /var/www/magento;
include /var/www/magento/nginx.conf.sample;
}
2.5.3 Start containers
docker-compose up -d
This will pull the images and start all services in the background.
2.5.4 Install Magento inside the PHP container
First, get a shell inside the PHP container:
docker-compose exec php bash
Now you are inside the container. Install Composer dependencies and run the installer.
Inside the container:
# Set up Composer authentication
composer global config http-basic.repo.magento.com <public-key> <private-key>
# If Magento is not yet installed, create project:
cd /var/www
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento
cd magento
# Run installer (note service names as hosts)
php bin/magento setup:install \
--base-url=http://localhost/ \
--db-host=db \
--db-name=magento \
--db-user=magento_user \
--db-password=StrongPassword123! \
--admin-firstname=Admin --admin-lastname=User --admin-email=admin@example.com \
--admin-user=admin --admin-password=Admin123! \
--language=en_US --currency=USD --timezone=America/New_York --use-rewrites=1 \
--search-engine=opensearch --opensearch-host=opensearch --opensearch-port=9200 \
--cache-backend=redis --cache-backend-redis-server=redis \
--page-cache=redis --page-cache-redis-server=redis \
--session-save=redis --session-save-redis-host=redis
Key differences: Use service names (db, opensearch, redis) as hostnames, not localhost.
After installation, exit the container (exit). You can now access Magento at http://localhost from your host machine.
2.6 Warden (Magento-specific Docker Orchestration)
Warden is a tool that generates a comprehensive docker-compose.yml tailored specifically for Magento 2, plus a warden command that wraps common Docker Compose operations. It includes Varnish, RabbitMQ, automatic local HTTPS certificates, and Mailhog — all pre‑configured.
Installation:
brew install wardenenv/warden/warden # macOS
# For Linux, follow instructions at https://warden.dev
Start Warden services:
warden svc up
Create a new Magento project:
mkdir -p ~/Sites/magento && cd ~/Sites/magento
warden env-init magento magento2
warden env up -d
Install Magento using Composer:
warden composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition .
Run the installer:
warden env exec php-fpm bin/magento setup:install \
--base-url=https://magento.test/ \
--db-host=db --db-name=magento --db-user=magento --db-password=magento \
--search-engine=opensearch --opensearch-host=opensearch --opensearch-port=9200 \
--cache-backend=redis --cache-backend-redis-server=redis \
--session-save=redis --session-save-redis-host=redis \
--admin-firstname=Admin --admin-lastname=User --admin-email=admin@example.com \
--admin-user=admin --admin-password=Admin123! \
--language=en_US --currency=USD --timezone=America/New_York --use-rewrites=1
Access: Open https://magento.test – it works with valid HTTPS without any manual certificate configuration.
2.7 DDEV
DDEV is another local development environment that works on all platforms and supports Magento out of the box. It’s similar to Warden but simpler.
Install DDEV:
- Follow instructions at ddev.com for your OS (brew, chocolatey, or script).
Create project:
mkdir my-magento-project && cd my-magento-project
ddev config --project-type=magento2 --docroot=pub --php-version=8.2
ddev start
Install Magento:
ddev composer create --repository-url=https://repo.magento.com/ magento/project-community-edition .
Run installer:
ddev exec bin/magento setup:install \
--base-url=https://my-magento-project.ddev.site \
--db-host=db --db-name=db --db-user=db --db-password=db \
--search-engine=opensearch --opensearch-host=opensearch --opensearch-port=9200 \
--cache-backend=redis --cache-backend-redis-server=redis \
--session-save=redis --session-save-redis-host=redis \
--admin-firstname=Admin --admin-lastname=User --admin-email=admin@example.com \
--admin-user=admin --admin-password=Admin123! \
--language=en_US --currency=USD --timezone=America/New_York --use-rewrites=1
Access: https://my-magento-project.ddev.site (automatically uses HTTPS).
2.8 Magento PWA Studio Setup
PWA Studio allows you to build a Progressive Web App storefront consuming Magento’s GraphQL APIs.
Prerequisites: Node.js 18.x or 20.x, Yarn 1.22+, a running Magento instance.
Step 1 – Install Node.js using nvm (or direct installer)
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
nvm install 20
nvm use 20
Step 2 – Install Yarn
npm install -g yarn
Step 3 – Create PWA project
npx @magento/pwa-buildpack create-pwa-app my-pwa-storefront
cd my-pwa-storefront
Step 4 – Configure environment variables
Create or edit .env file:
MAGENTO_BACKEND_URL=http://magento.local
GRAPHQL_ENDPOINT=/graphql
STORE_VIEW_CODE=default
PORT=3000
Step 5 – Install dependencies and build
yarn install
yarn build
Step 6 – Configure CORS on Magento side
Your Magento backend must allow requests from the PWA origin (http://localhost:3000). The simplest approach is to create a plugin.
Create a new module, e.g., MyCompany/PwaCors, with the following files:
app/code/MyCompany/PwaCors/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\GraphQl\Controller\HttpRequestProcessor">
<plugin name="add_cors_headers" type="MyCompany\PwaCors\Plugin\AddCorsHeaders"/>
</type>
</config>
app/code/MyCompany/PwaCors/Plugin/AddCorsHeaders.php
<?php
namespace MyCompany\PwaCors\Plugin;
use Magento\GraphQl\Controller\HttpRequestProcessor;
use Magento\Framework\App\Response\Http;
class AddCorsHeaders
{
public function afterProcess(HttpRequestProcessor $subject, Http $response): Http
{
$response->setHeader('Access-Control-Allow-Origin', 'http://localhost:3000');
$response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
$response->setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return $response;
}
}
Then register the module and run php bin/magento setup:upgrade.
Step 7 – Run the PWA development server
yarn watch
The storefront will be available at http://localhost:3000.
Step 8 – Build for production
yarn build
yarn start
The built files are in the dist/ folder, which you can deploy to a web server or CDN.
2.9 IDE Configuration
A good IDE significantly speeds up development. We cover PhpStorm and VS Code.
2.9.1 PhpStorm
- Install PhpStorm from JetBrains.
- Install the Magento PhpStorm plugin:
- Go to Settings → Plugins.
- Search for “Magento” and install the official plugin (by JetBrains or Magento).
- Open your Magento project: File → Open → select the Magento root folder.
- Enable Magento 2 support:
- Go to Settings → Languages & Frameworks → PHP → Magento.
- Check “Enable Magento 2 support”.
- Set “Magento Root” to your project root.
- Set PHP interpreter: Go to Settings → Languages & Frameworks → PHP and choose the PHP 8.2 interpreter (if not detected, add it).
- Configure Code Style: You can import Magento Coding Standard (PSR-12 based) from the project or download it.
- Test: Ctrl+click on an interface (e.g.,
ProductInterface) – PhpStorm should jump to its declaration. If it works, the plugin is active.
2.9.2 VS Code
- Install PHP Intelephense (by Ben Mewburn) for advanced code intelligence.
- Install PHP Debug (by Felix Becker) for Xdebug integration.
- Open the Magento folder.
- For Xdebug, create a launch configuration:
- Create
.vscode/launch.jsonwith:
{
"version": "0.2.0",
"configurations": [
{
"name": "Listen for Xdebug",
"type": "php",
"request": "launch",
"port": 9003
}
]
}
- Set breakpoints and start debugging (F5).
2.10 Xdebug — Step Debugging
Xdebug allows you to step through your code, inspect variables, and understand the execution flow. It’s essential for debugging Magento.
2.10.1 Install Xdebug
Linux (Ubuntu/Debian):
sudo apt install php8.2-xdebug -y
macOS (Homebrew):
pecl install xdebug
Windows:
- Visit xdebug.org/wizard.
- Paste the output of
php -iinto the text box. - Download the recommended DLL file and place it in
C:\php\ext. - Edit
php.iniand add:
zend_extension=php_xdebug-3.x.x-8.2-vs16-x86_64.dll
(adjust filename)
2.10.2 Configure Xdebug
Edit your php.ini (find its location with php --ini) and add:
zend_extension=xdebug.so # or the .dll on Windows
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=127.0.0.1
xdebug.client_port=9003
Restart PHP-FPM (or web server) after changes.
2.10.3 Set up IDE
In PhpStorm, click the “Start Listening for PHP Debug Connections” button (telephone icon). In VS Code, start the “Listen for Xdebug” configuration.
Set a breakpoint in a controller’s execute() method and reload the corresponding page in your browser. The execution will pause, and you can inspect variables, step through code, etc.
If the browser loads normally without pausing:
- Did you restart PHP-FPM?
- Run
php --inito verify the correct php.ini is loaded. - Is your IDE in listening mode at the moment you reload?
- Firewall software might block port 9003.
- Check if Xdebug is loaded:
php -m | grep xdebug.
2.11 Post-Installation Configuration, File Permissions & Mode Setup (Applicable to All Environments)
After installation, perform these steps to ensure a smooth development experience.
2.11.1 Set correct file permissions (Linux/macOS/WSL)
find var generated vendor pub/static pub/media app/etc -type f -exec chmod g+w {} +
find var generated vendor pub/static pub/media app/etc -type d -exec chmod g+ws {} +
chown -R :www-data . # Adjust group to www-data or your web server group
chmod u+x bin/magento
On macOS, if PHP-FPM runs as your user, you may skip the chown and just run the find commands with sudo if needed.
2.11.2 Enable developer mode
php bin/magento deploy:mode:set developer
Developer mode disables caching and enables verbose error messages.
2.11.3 Flush caches
php bin/magento cache:flush
2.11.4 Reindex all indexers
php bin/magento indexer:reindex
2.11.5 Compile dependency injection (optional in developer mode)
php bin/magento setup:di:compile
This is required in production but optional in developer mode (Magento compiles on the fly).
2.11.6 Verify installation
- Frontend:
http://magento.local/(or your domain) – you should see the Luma store. - Admin:
http://magento.local/admin_xxxx– log in with the admin credentials you set.
2.12 Environment Setup: PhpStorm, Composer, MySQL Workbench, Postman
These tools help you manage your Magento project effectively.
2.12.1 Composer configuration (global)
Set the Magento repository credentials globally (so you don’t have to do it per project):
composer config --global http-basic.repo.magento.com your_public_key your_private_key
2.12.2 MySQL Workbench
Install MySQL Workbench (available from mysql.com). Create a new connection:
- Connection Name: Magento Local
- Hostname: 127.0.0.1 (or localhost)
- Port: 3306
- Username: magento_user (or root)
- Password: (the one you set)
2.12.3 Postman
Install Postman from postman.com. Import the Magento REST API collection (you can find it in the Magento documentation or community). Set up environment variables:
base_url=http://magento.local/restadmin_tokenetc.
2.13 Installation Methods (Summary)
- Composer (Recommended):
composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento2 - Manual Installation (ZIP Archive): Download a pre‑packaged ZIP from magento.com and extract to web root.
- Marketplace (Adobe Commerce Cloud):
magento-cloud project:create(for Adobe Commerce Cloud customers).
2.14 Troubleshooting Common Issues
Here are frequent problems and their solutions.
| Problem | Likely cause | Solution |
|---|---|---|
| “Access denied” for MySQL | Wrong username/password or privileges not granted. | Re‑run CREATE USER and GRANT as root. |
| OpenSearch not responding | Container not started or still initialising. | Wait 30s; check logs with docker logs opensearch. |
| Composer 401 Unauthorized | Invalid Magento access keys. | Re‑check keys and global config; run composer global config -l to verify. |
| White screen after install | Permissions issue or missing generated code. | Check var/log/system.log and var/log/exception.log; set correct permissions; run php bin/magento setup:upgrade. |
| Nginx 502 Bad Gateway | PHP-FPM is not running or socket path wrong. | Restart PHP-FPM; verify socket exists and is readable by Nginx. |
| Static files (CSS/JS) not loading | Static content deployment needed. | Run php bin/magento setup:static-content:deploy -f (in developer mode). |
| Admin URL not working | Wrong admin URI or base URL. | Check app/etc/env.php for admin frontName; try php bin/magento info:adminuri. |
2.15 Summary
You have now set up a fully functional Magento development environment on your operating system of choice. Remember to:
- Always use version control (Git).
- Keep your environment updated.
- Use Xdebug for debugging.
- Leverage IDE plugins to speed up development.
- Test your code in both developer and production modes.
With this foundation, you are ready to start building Magento modules, themes, and customisations. Happy coding!
Chapter 3: Magento Architecture & Internal Working
3.1 High-Level Architecture Overview (MVC, Modules, Service Contracts)
Magento follows the Model-View-Controller (MVC) pattern but extends it with additional layers to support modularity, dependency injection, and service contracts.
MVC in Magento:
| Component | Role in Magento | Typical Location |
|---|---|---|
| Model | Business logic, database interactions, data management | Vendor/Module/Model/ |
| View | Renders UI using layout XML and PHTML templates | Vendor/Module/view/frontend/layout/ and templates/ |
| Controller | Accepts HTTP requests, calls models/services, returns results | Vendor/Module/Controller/ |
Modules are self-contained units providing specific functionality (e.g., Magento_Catalog, Magento_Customer, Magento_Sales). They can be enabled, disabled, or replaced without affecting the whole system.
Service Contracts are PHP interfaces that define the public API of a module. They guarantee backward compatibility between Magento versions.
Example:
<?php
namespace Magento\Catalog\Api;
interface ProductRepositoryInterface
{
public function get(string $sku, bool $editMode = false, ?int $storeId = null): ProductInterface;
public function save(ProductInterface $product): ProductInterface;
public function delete(ProductInterface $product): bool;
}
Your custom code should always type-hint the interface, not the concrete class:
// Good — depends on abstraction
public function __construct(\Magento\Catalog\Api\ProductRepositoryInterface $productRepository) {}
// Bad — tightly coupled, may break after upgrade
public function __construct(\Magento\Catalog\Model\ProductRepository $productRepository) {}
3.2 Dependency Injection (DI) – Conceptual Overview
Dependency Injection is a design pattern where a class receives its dependencies from an external source (the DI container) rather than creating them internally. In Magento, the DI container automatically instantiates and injects required objects based on type declarations in constructors.
Without DI (hardcoded, bad practice):
class BadExample
{
public function doSomething()
{
$logger = new \Psr\Log\Logger(); // hardcoded — impossible to test or replace
$logger->info('message');
}
}
With DI:
class GoodExample
{
public function __construct(
private readonly \Psr\Log\LoggerInterface $logger
) {}
public function doSomething()
{
$this->logger->info('message');
}
}
Magento reads di.xml files to know which concrete class to provide for each interface — called a preference:
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="\Psr\Log\LoggerInterface" type="\Magento\Framework\Logger\Monolog"/>
</config>
3.3 Request Flow & Execution Pipeline
Step-by-step flow:
Browser Request
│
▼
pub/index.php (initialise)
│
▼
Bootstrap (create app, Object Manager)
│
▼
Router (match route via routes.xml)
│
▼
Controller Action (execute logic)
│
▼
Model / Service (business data)
│
▼
Layout XML (merge definitions from all modules)
│
▼
Blocks (prepare data for templates)
│
▼
Templates (render HTML via PHTML)
│
▼
Response (send to browser)
Caching layers: After the response is generated, Full Page Cache (if enabled and cacheable) stores the output. Subsequent requests bypass the entire pipeline and are served directly from cache.
Entry point — All requests are routed through pub/index.php. This file initializes the Magento application via Bootstrap::create(). The router detects the area (frontend, adminhtml, or webapi) and creates the corresponding application instance. The matched controller action runs, returns a result object (Page, Redirect, Json), and the response is sent to the browser.
3.4 Module System and Folder Structure
A Magento module lives in app/code/Vendor/Module/. It must contain at minimum:
registration.php— tells Magento this folder is a module:
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MyCompany_HelloWorld',
__DIR__
);
etc/module.xml— declares the module name, version, and dependencies:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_HelloWorld" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
</sequence>
</module>
</config>
Standard module directory tree:
app/code/MyCompany/HelloWorld/
├── registration.php
├── composer.json
├── etc/
│ ├── module.xml
│ ├── di.xml
│ ├── acl.xml
│ ├── config.xml
│ ├── events.xml
│ ├── frontend/
│ │ └── routes.xml
│ └── adminhtml/
│ ├── routes.xml
│ ├── menu.xml
│ └── system.xml
├── Api/ (service contract interfaces)
├── Model/ (business logic, resource models)
├── Controller/ (frontend and admin controllers)
├── Block/ (frontend and admin blocks)
├── view/
│ ├── frontend/
│ │ ├── layout/
│ │ ├── templates/
│ │ └── web/
│ └── adminhtml/
│ ├── layout/
│ ├── templates/
│ └── web/
├── Setup/
│ └── Patch/
│ ├── Schema/
│ └── Data/
├── Console/ (CLI command classes)
└── i18n/ (translation CSV files)
3.5 Detailed Project Folder Structure
magento2/
├── app/
│ ├── code/ # Custom and third-party modules
│ ├── design/ # Themes (frontend and adminhtml)
│ └── etc/
│ ├── di.xml
│ └── env.php # Environment-specific config (database, cache)
├── bin/
│ └── magento # CLI tool
├── generated/ # Generated code (factories, interceptors) — never edit manually
├── pub/ # Public web root (document root for web server)
│ ├── index.php # Entry point for all requests
│ ├── static/ # Deployed static assets (CSS, JS, fonts)
│ └── media/ # Product images, customer uploads
├── var/ # Variable files (writable by web server)
│ ├── cache/ # Cache storage (if not using Redis)
│ ├── log/ # system.log, exception.log
│ └── session/ # PHP session files
├── vendor/ # Composer dependencies (core and third-party)
├── composer.json
└── composer.lock
Important notes:
- Never edit files in
vendor/orgenerated/— they are managed by Composer and Magento pub/is the only folder that should be exposed to the webapp/etc/env.phpcontains sensitive data — do not commit this file to version control
3.6 Design Patterns in Magento
- Factory Pattern — provides a way to create objects without exposing instantiation logic. Magento automatically generates factory classes for any class ending with
Factory:
public function __construct(
private \Magento\Catalog\Model\ProductFactory $productFactory
) {}
public function createProduct()
{
$product = $this->productFactory->create();
$product->setName('New Product');
return $product;
}
- Singleton Pattern — implemented via the DI container (not via
getInstance()). When a class is marked asshared="true"in di.xml, the same instance is reused for all injections. - Repository Pattern — Separates the application from the underlying data storage mechanism by providing an abstraction layer for data access.
namespace Magento\Catalog\Api;
interface ProductRepositoryInterface
{
public function getById($productId);
public function save(ProductInterface $product);
public function delete(ProductInterface $product);
}
- Observer Pattern — events dispatched at key points, observers react without modifying the original flow.
- Strategy Pattern — payment methods and shipping methods are interchangeable strategies.
Never use the Service Locator anti-pattern:
// WRONG — do not use ObjectManager directly
$product = \Magento\Framework\App\ObjectManager::getInstance()
->get(\Magento\Catalog\Model\Product::class);
// CORRECT — use constructor injection
public function __construct(
private \Magento\Catalog\Model\ProductFactory $productFactory
) {}
PART TWO: BACKEND DEVELOPMENT & CORE SYSTEMS
Chapter 4: PHP Foundations for Magento
Before writing a single line of Magento module code, master the PHP language features Magento uses heavily. Magento is an enterprise-grade object-oriented system that depends on modern PHP features.
4.1 Variables and Types
$storeName = "Magento Store"; // string
$productCount = 150; // integer
$price = 49.95; // float
$isAvailable = true; // boolean
$attributeCodes = ['color', 'size']; // array
$customer = null; // null
Type declarations are essential in Magento. You will see them in every method signature:
public function setPrice(float $price): void
{
$this->price = $price;
}
4.2 Arrays
Associative arrays are more common in Magento configuration:
$config = [
'db' => [
'host' => 'localhost',
'username' => 'magento_user'
],
'cache' => [
'backend' => 'redis'
]
];
Important Magento-specific pattern: The DataObject stores all its data in an internal $_data array. This allows Magento to handle dynamic attributes without declaring hundreds of properties:
$product = $productRepository->get('sku123');
$product->setData('my_custom_attribute', 'value');
echo $product->getData('my_custom_attribute');
4.3 Classes and Objects
namespace MyCompany\Catalog\Model;
use Magento\Framework\DataObject;
class Product extends DataObject
{
private string $sku;
public function getSku(): string
{
return $this->sku;
}
public function setSku(string $sku): void
{
$this->sku = $sku;
}
}
Visibility modifiers: public — accessible from anywhere; protected — accessible within this class and children; private — accessible only within this exact class.
In Magento, you never use new directly for most classes. Instead, use factories or dependency injection:
// Correct: inject factory, use it to create
public function __construct(\Magento\Catalog\Model\ProductFactory $productFactory)
{
$this->productFactory = $productFactory;
}
public function createProduct()
{
$product = $this->productFactory->create();
$product->setSku('test');
return $product;
}
4.4 Interfaces
Interfaces define contracts — they list methods that a class must implement, without providing the implementation.
namespace Magento\Catalog\Api;
interface ProductRepositoryInterface
{
public function get(string $sku): ProductInterface;
public function save(ProductInterface $product): ProductInterface;
public function delete(ProductInterface $product): bool;
}
How Magento maps interfaces to implementations — via di.xml preference:
<preference for="Magento\Catalog\Api\ProductRepositoryInterface"
type="Magento\Catalog\Model\ProductRepository" />
4.5 Traits
trait LoggerTrait
{
protected function logError(string $message): void
{
$this->logger->error($message);
}
}
class SomeService
{
use LoggerTrait;
private $logger;
public function doSomething()
{
$this->logError('Something failed');
}
}
Traits cannot have their own constructor parameters. If a trait needs dependencies, those must be defined as abstract methods or as properties that the using class must provide.
4.6 Namespaces
Magento follows PSR-4 autoloading where the namespace directly maps to a directory path:
namespace MyCompany\Catalog\Controller\Index;
use Magento\Framework\App\ActionInterface;
use Magento\Framework\View\Result\PageFactory;
class View implements ActionInterface
{
// Now you can use ActionInterface and PageFactory directly
}
Magento’s conventions:
Api— for interfaces (service contracts)Model— for business logic implementationsController— for controllersBlock— for blocksSetup— for installation and upgrade scripts
4.7 Error Handling and Exceptions
Magento’s exception hierarchy:
LocalizedException— for business logic errors to display to usersNoSuchEntityException— when a requested entity doesn’t existInputException— for validation errorsCouldNotSaveException— when a save operation fails
use Magento\Framework\Exception\NoSuchEntityException;
public function loadProduct($productId)
{
$product = $this->productRepository->getById($productId);
if (!$product->getId()) {
throw new NoSuchEntityException(__('Product with id %1 does not exist.', $productId));
}
return $product;
}
Catching exceptions — catch specific exceptions where you can handle them:
try {
$product = $this->productRepository->get($sku);
} catch (NoSuchEntityException $e) {
$this->logger->warning($e->getMessage());
$product = $this->createDefaultProduct();
}
4.8 SOLID Principles
- Single Responsibility (SRP) — A class should have only one reason to change.
ProductRepositoryis responsible for loading and saving products, not for rendering HTML or sending emails. - Open/Closed (OCP) — Classes should be open for extension but closed for modification. In Magento, extend behavior using plugins rather than editing core classes.
- Liskov Substitution (LSP) — Subtypes must be substitutable for their base types. If you extend
AbstractProductand override a method, the method must still fulfil the same contract. - Interface Segregation (ISP) — Many small interfaces are better than one large one.
ProductRepositoryInterfaceonly hasget,save,delete— not unrelated operations. - Dependency Inversion (DIP) — Depend on abstractions, not concretions. Always type-hint interfaces in constructors, not concrete classes.
4.9 Design Patterns
Factory Pattern:
public function __construct(\Magento\Catalog\Model\ProductFactory $productFactory) {}
public function createProduct()
{
$product = $this->productFactory->create();
$product->setSku('new-sku');
$this->productRepository->save($product);
}
Repository Pattern:
$product = $this->productRepository->get('sku123');
$product->setPrice(49.99);
$this->productRepository->save($product);
Observer Pattern:
// Dispatch event
$this->eventManager->dispatch('catalog_product_save_after', ['product' => $product]);
// Observer class
class ProductSaveAfter implements ObserverInterface
{
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
$this->logger->info('Product saved: SKU ' . $product->getSku());
}
}
Strategy Pattern — payment methods:
interface PaymentMethodInterface
{
public function authorize(): bool;
public function capture(): bool;
public function refund(): bool;
}
Singleton Pattern — via DI container:
<type name="MyCompany\HelloWorld\Model\NonSingleton">
<arguments>
<argument name="shared" xsi:type="boolean">false</argument>
</arguments>
</type>
Chapter 5: Your First Magento Module — Hello World
Welcome to your first real Magento module. You will build a module that outputs “Hello, Magento World!” on a custom route, understanding every moving part.
Module name: MyCompany_HelloWorld — accessible at http://magento.local/helloworld/index/index
5.1 Creating the Module Folder Structure
cd /path/to/magento2
mkdir -p app/code/MyCompany/HelloWorld/etc/frontend
mkdir -p app/code/MyCompany/HelloWorld/Controller/Index
mkdir -p app/code/MyCompany/HelloWorld/view/frontend/layout
mkdir -p app/code/MyCompany/HelloWorld/view/frontend/templates
5.2 registration.php
Path: app/code/MyCompany/HelloWorld/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MyCompany_HelloWorld',
__DIR__
);
ComponentRegistrar::MODULE tells Magento this is a module (not a theme, language pack, or library). 'MyCompany_HelloWorld' is the unique module identifier. __DIR__ returns the absolute path of the current file’s directory.
5.3 module.xml
Path: app/code/MyCompany/HelloWorld/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_HelloWorld" setup_version="1.0.0" />
</config>
The name must match registration.php. setup_version tracks the database schema version — when you run bin/magento setup:upgrade, Magento compares this with the stored version and runs any new patches.
5.4 composer.json (Optional but Recommended)
Path: app/code/MyCompany/HelloWorld/composer.json
{
"name": "mycompany/module-hello-world",
"description": "My first Magento 2 module - Hello World",
"type": "magento2-module",
"version": "1.0.0",
"require": {
"php": "~8.1.0||~8.2.0",
"magento/framework": "*"
},
"autoload": {
"files": ["registration.php"],
"psr-4": {
"MyCompany\\HelloWorld\\": ""
}
}
}
5.5 Routes
Path: app/code/MyCompany/HelloWorld/etc/frontend/routes.xml
<?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="helloworld" frontName="helloworld">
<module name="MyCompany_HelloWorld" />
</route>
</router>
</config>
router id="standard" handles HTTP requests for the frontend area. frontName="helloworld" is the first segment of the URL after the base URL. When a request comes to /helloworld/controller/action, Magento looks inside MyCompany_HelloWorld/Controller/ for the controller file.
5.6 Controller
Path: app/code/MyCompany/HelloWorld/Controller/Index/Index.php
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
private readonly PageFactory $resultPageFactory;
public function __construct(PageFactory $resultPageFactory)
{
$this->resultPageFactory = $resultPageFactory;
}
public function execute()
{
return $this->resultPageFactory->create();
}
}
HttpGetActionInterface marks this controller as responding only to HTTP GET requests. execute() is the only method Magento requires. PageFactory is injected by Magento’s DI automatically — creating a Page result tells Magento to generate a full HTML page using the layout system.
5.7 Layout XML
Path: app/code/MyCompany/HelloWorld/view/frontend/layout/helloworld_index_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<title>Hello World Page</title>
</head>
<body>
<referenceContainer name="content">
<block class="MyCompany\HelloWorld\Block\Hello"
name="hello.world.block"
template="MyCompany_HelloWorld::hello.phtml" />
</referenceContainer>
</body>
</page>
The file name helloworld_index_index.xml is built from the route, controller, and action. layout="1column" uses the one-column page layout. referenceContainer name="content" adds content to the main content area.
5.8 Block Class
Path: app/code/MyCompany/HelloWorld/Block/Hello.php
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Block;
use Magento\Framework\View\Element\Template;
class Hello extends Template
{
public function getGreeting(): string
{
return __('Hello, Magento World from custom block!');
}
}
The block extends Template, inheriting all template-rendering capabilities. We add getGreeting() returning a translated string. In the template, we can call $block->getGreeting().
5.9 Template
Path: app/code/MyCompany/HelloWorld/view/frontend/templates/hello.phtml
<?php
/** @var $block \MyCompany\HelloWorld\Block\Hello */
?>
<div class="hello-world-message" style="text-align: center; margin-top: 50px;">
<h1><?= $block->escapeHtml($block->getGreeting()) ?></h1>
<p><?= __('This is my first custom Magento module.') ?></p>
</div>
$block is the block instance passed from Magento’s layout. escapeHtml() prevents XSS attacks by converting special characters to HTML entities — always escape dynamic output. __('...') translates the string.
5.10 Deployment
# Enable the module
php bin/magento module:enable MyCompany_HelloWorld
# Run setup upgrade (registers module in database)
php bin/magento setup:upgrade
# Deploy static content (CSS, JS)
php bin/magento setup:static-content:deploy -f
# Clear the cache
php bin/magento cache:flush
# Verify
php bin/magento module:status MyCompany_HelloWorld
Open http://magento.local/helloworld/index/hello — you should see the centered “Hello, Magento World from custom block!” message.
Troubleshooting:
- 404 page: check routes.xml is correct and cache was flushed
- Blank page: check
var/log/exception.logfor class not found errors - Wrong version showing: run
bin/magento setup:di:compileto regenerate DI
Best practices:
- Use
declare(strict_types=1)at the top of every PHP file - Always implement interfaces for controllers (
HttpGetActionInterface,HttpPostActionInterface) - Use dependency injection, never
ObjectManagerdirectly - Escape all output in templates using
$block->escapeHtml() - Use translation function
__()for all user-facing strings - Keep controllers thin — delegate to models/services
Chapter 6: Beginner Magento Concepts & Admin Panel
6.1 Configuration Basics (system.xml, config.xml)
We will create a real module called MyCompany_Blog that adds a configuration section in the admin panel.
Step 1: Create the Module Structure
cd /path/to/magento2
mkdir -p app/code/MyCompany/Blog/etc
mkdir -p app/code/MyCompany/Blog/etc/adminhtml
mkdir -p app/code/MyCompany/Blog/Helper
Step 2: config.xml – Default Values
Path: app/code/MyCompany/Blog/etc/config.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Store:etc/config.xsd">
<default>
<mycompany_blog>
<general>
<enabled>1</enabled>
<posts_per_page>10</posts_per_page>
<meta_title>Default Blog Title</meta_title>
</general>
</mycompany_blog>
</default>
</config>
Step 3: system.xml – Admin Form Fields
Path: app/code/MyCompany/Blog/etc/adminhtml/system.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Config:etc/system_file.xsd">
<system>
<tab id="mycompany" translate="label" sortOrder="400">
<label>My Company</label>
</tab>
<section id="mycompany_blog" translate="label" type="text" sortOrder="100"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Blog Settings</label>
<tab>mycompany</tab>
<resource>MyCompany_Blog::config</resource>
<group id="general" translate="label" type="text" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>General Settings</label>
<field id="enabled" translate="label" type="select" sortOrder="10"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Enable Blog</label>
<source_model>Magento\Config\Model\Config\Source\Yesno</source_model>
</field>
<field id="posts_per_page" translate="label" type="text" sortOrder="20"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Posts Per Page</label>
<validate>required-entry validate-number validate-greater-than-zero</validate>
</field>
<field id="meta_title" translate="label" type="text" sortOrder="30"
showInDefault="1" showInWebsite="1" showInStore="1">
<label>Blog Page Title</label>
</field>
</group>
</section>
</system>
</config>
Step 4: Helper to Read Configuration
Path: app/code/MyCompany/Blog/Helper/Config.php
<?php
namespace MyCompany\Blog\Helper;
use Magento\Framework\App\Helper\AbstractHelper;
use Magento\Store\Model\ScopeInterface;
class Config extends AbstractHelper
{
const XML_PATH_ENABLED = 'mycompany_blog/general/enabled';
const XML_PATH_POSTS_PER_PAGE = 'mycompany_blog/general/posts_per_page';
const XML_PATH_META_TITLE = 'mycompany_blog/general/meta_title';
public function isEnabled($storeId = null)
{
return $this->scopeConfig->isSetFlag(
self::XML_PATH_ENABLED,
ScopeInterface::SCOPE_STORE,
$storeId
);
}
public function getPostsPerPage($storeId = null)
{
return (int) $this->scopeConfig->getValue(
self::XML_PATH_POSTS_PER_PAGE,
ScopeInterface::SCOPE_STORE,
$storeId
);
}
public function getMetaTitle($storeId = null)
{
return $this->scopeConfig->getValue(
self::XML_PATH_META_TITLE,
ScopeInterface::SCOPE_STORE,
$storeId
);
}
}
Step 5: Register the Module
Path: app/code/MyCompany/Blog/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::MODULE, 'MyCompany_Blog', __DIR__);
Path: app/code/MyCompany/Blog/etc/module.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_Blog" setup_version="1.0.0"/>
</config>
Step 6: Install and Test
php bin/magento module:enable MyCompany_Blog
php bin/magento setup:upgrade
php bin/magento cache:flush
Go to Stores → Configuration → My Company → Blog Settings. You will see the three fields. Change values and save. Then in PHP code you can retrieve them using the helper.
6.2 Understanding Modules (Lifecycle, Dependencies, Sequencing)
Real Example: Adding a Dependency
Suppose your MyCompany_Blog module needs to use product data. It must be loaded after Magento_Catalog.
Path: app/code/MyCompany/Blog/etc/module.xml (updated)
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_Blog" setup_version="1.0.0">
<sequence>
<module name="Magento_Catalog"/>
<module name="Magento_Customer"/>
</sequence>
</module>
</config>
Module Lifecycle Commands:
# See all modules with status
php bin/magento module:status
# Enable (also runs setup:upgrade automatically)
php bin/magento module:enable MyCompany_Blog
# Disable
php bin/magento module:disable MyCompany_Blog
# Uninstall (removes data – must be declared as uninstallable)
php bin/magento module:uninstall --remove-data MyCompany_Blog
6.3 Magento CLI Basics – Complete Command Reference
# ────────────────────────────────────────────
# Module Management
# ────────────────────────────────────────────
php bin/magento module:enable Vendor_Module
php bin/magento module:disable Vendor_Module
php bin/magento module:status
php bin/magento module:uninstall --remove-data Vendor_Module
# ────────────────────────────────────────────
# Setup & Compilation
# ────────────────────────────────────────────
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento setup:static-content:deploy en_US de_DE
php bin/magento setup:static-content:deploy --strategy=compact
# ────────────────────────────────────────────
# Cache Management
# ────────────────────────────────────────────
php bin/magento cache:flush
php bin/magento cache:clean
php bin/magento cache:disable layout block_html full_page
php bin/magento cache:enable layout
# ────────────────────────────────────────────
# Indexer Management
# ────────────────────────────────────────────
php bin/magento indexer:reindex
php bin/magento indexer:reindex catalog_product_price
php bin/magento indexer:status
php bin/magento indexer:set-mode schedule catalog_product_price
php bin/magento indexer:set-mode realtime
# ────────────────────────────────────────────
# Mode Management
# ────────────────────────────────────────────
php bin/magento deploy:mode:set developer
php bin/magento deploy:mode:set production
php bin/magento deploy:mode:show
# ────────────────────────────────────────────
# Configuration Management
# ────────────────────────────────────────────
php bin/magento config:set web/unsecure/base_url http://localhost/
php bin/magento config:show web/unsecure/base_url
php bin/magento config:set --scope=stores --scope-code=default mycompany_blog/general/enabled 1
# ────────────────────────────────────────────
# Admin User Management
# ────────────────────────────────────────────
php bin/magento admin:user:create \
--admin-user=admin \
--admin-password=Admin123! \
--admin-email=admin@example.com \
--admin-firstname=Admin \
--admin-lastname=User
# ────────────────────────────────────────────
# Cron
# ────────────────────────────────────────────
php bin/magento cron:run
php bin/magento cron:install
php bin/magento cron:remove
# ────────────────────────────────────────────
# Maintenance Mode
# ────────────────────────────────────────────
php bin/magento maintenance:enable
php bin/magento maintenance:disable
php bin/magento maintenance:status
php bin/magento maintenance:allow-ips 192.168.1.1
# ────────────────────────────────────────────
# Developer Tools
# ────────────────────────────────────────────
php bin/magento dev:template-hints:enable
php bin/magento dev:template-hints:disable
php bin/magento dev:profiler:enable
php bin/magento dev:profiler:disable
php bin/magento i18n:collect-phrases -o output.csv app/code/MyCompany/Blog
# ────────────────────────────────────────────
# Queue (RabbitMQ)
# ────────────────────────────────────────────
php bin/magento queue:consumers:list
php bin/magento queue:consumer:start async.operations.all
# ────────────────────────────────────────────
# Sample Data
# ────────────────────────────────────────────
php bin/magento sampledata:deploy
php bin/magento sampledata:remove
# ────────────────────────────────────────────
# Catalog & Images
# ────────────────────────────────────────────
php bin/magento catalog:images:resize
php bin/magento catalog:product:attributes:cleanup
6.4 Themes, Templates, and Layout XML Overview
Creating a Custom Theme (Real Example):
Step 1: Create theme directory
mkdir -p app/design/frontend/MyCompany/mytheme
mkdir -p app/design/frontend/MyCompany/mytheme/Magento_Theme/layout
mkdir -p app/design/frontend/MyCompany/mytheme/web/css
Step 2: Create theme.xml
Path: app/design/frontend/MyCompany/mytheme/theme.xml
<theme xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/theme.xsd">
<title>My Custom Theme</title>
<parent>Magento/blank</parent>
</theme>
Step 3: Create registration.php
Path: app/design/frontend/MyCompany/mytheme/registration.php
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(ComponentRegistrar::THEME, 'frontend/MyCompany/mytheme', __DIR__);
Step 4: Add custom CSS
Path: app/design/frontend/MyCompany/mytheme/web/css/custom.css
body {
background-color: #f5f5f5;
}
Step 5: Include CSS via layout
Path: app/design/frontend/MyCompany/mytheme/Magento_Theme/layout/default_head_blocks.xml
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="css/custom.css"/>
</head>
</page>
Step 6: Apply the theme
Go to Content → Design → Configuration. Edit the default store view, set Applied Theme to MyCompany/mytheme. Flush cache: php bin/magento cache:flush.
Layout XML Real Example – Adding a Banner to Homepage:
Path: app/design/frontend/MyCompany/mytheme/Magento_Cms/layout/cms_index_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="Magento\Framework\View\Element\Template"
name="homepage.banner"
template="MyCompany_MyTheme::banner.phtml"
before="-"/>
</referenceContainer>
</body>
</page>
Template: app/design/frontend/MyCompany/mytheme/templates/banner.phtml
<div class="home-banner">
<h2>Welcome to Our Store!</h2>
<p>Special offer today</p>
</div>
6.5 Magento Database Structure: EAV Model and Core Tables
Understanding EAV – Real Example:
EAV (Entity-Attribute-Value) stores attributes in separate rows. For example, a product color attribute can be added without altering the catalog_product_entity table.
Core EAV tables:
eav_entity_type– defines entity types (product, customer, category, etc.)eav_attribute– defines each attribute (code, frontend label, backend type)catalog_product_entity_varchar– stores text values for product attributescatalog_product_entity_int,decimal,datetime,text– for other types
Create a Custom EAV Attribute (Product Attribute) – Complete Step-by-Step:
Step 1: Create a data patch
Path: app/code/MyCompany/Blog/Setup/Patch/Data/AddMaterialAttribute.php
<?php
namespace MyCompany\Blog\Setup\Patch\Data;
use Magento\Eav\Setup\EavSetup;
use Magento\Eav\Setup\EavSetupFactory;
use Magento\Framework\Setup\ModuleDataSetupInterface;
use Magento\Framework\Setup\Patch\DataPatchInterface;
class AddMaterialAttribute implements DataPatchInterface
{
private ModuleDataSetupInterface $moduleDataSetup;
private EavSetupFactory $eavSetupFactory;
public function __construct(
ModuleDataSetupInterface $moduleDataSetup,
EavSetupFactory $eavSetupFactory
) {
$this->moduleDataSetup = $moduleDataSetup;
$this->eavSetupFactory = $eavSetupFactory;
}
public function apply()
{
$this->moduleDataSetup->startSetup();
/** @var EavSetup $eavSetup */
$eavSetup = $this->eavSetupFactory->create(['setup' => $this->moduleDataSetup]);
$eavSetup->addAttribute(
\Magento\Catalog\Model\Product::ENTITY,
'material',
[
'type' => 'varchar',
'label' => 'Material',
'input' => 'text',
'required' => false,
'visible_on_front' => true,
'used_in_product_listing' => true,
'user_defined' => true,
'global' => \Magento\Eav\Model\Entity\Attribute\ScopedAttributeInterface::SCOPE_STORE,
'group' => 'General'
]
);
$this->moduleDataSetup->endSetup();
return $this;
}
public static function getDependencies()
{
return [];
}
public function getAliases()
{
return [];
}
}
Step 2: Run setup
php bin/magento module:enable MyCompany_Blog
php bin/magento setup:upgrade
Step 3: Use the attribute in code
// Save product with material value
$product->setMaterial('Cotton');
$product->save();
// Retrieve product material
$material = $product->getMaterial(); // 'Cotton'
Core Tables You Must Know:
| Table | Purpose |
|---|---|
| catalog_product_entity | Base product info (sku, created_at) |
| catalog_product_entity_varchar | Text values for product attributes |
| catalog_product_entity_int | Integer values |
| catalog_product_entity_decimal | Decimal values (price, weight) |
| eav_attribute | All attribute definitions |
| customer_entity | Customer base info |
| sales_order | Order headers |
| sales_order_item | Order line items |
| quote | Shopping cart |
| store | Store views |
| admin_user | Admin users |
| cron_schedule | Cron job records |
Example Query to See EAV in Action:
SELECT
pe.sku,
ev.value AS product_name,
ed.value AS price
FROM catalog_product_entity pe
LEFT JOIN catalog_product_entity_varchar ev ON pe.entity_id = ev.entity_id
AND ev.attribute_id = (SELECT attribute_id FROM eav_attribute WHERE attribute_code = 'name')
LEFT JOIN catalog_product_entity_decimal ed ON pe.entity_id = ed.entity_id
AND ed.attribute_id = (SELECT attribute_id FROM eav_attribute WHERE attribute_code = 'price')
LIMIT 10;
6.6 Admin Panel Overview
Key Sections:
- Products (Catalog → Products): Add simple/configurable products, assign categories, upload images
- Categories (Catalog → Categories): Create and manage category hierarchy
- Orders (Sales → Orders): View, invoice, ship, or cancel orders
- Promotions (Marketing → Cart Price Rules): Create discount rules
- Stores (Stores → All Stores): Create store views for different languages
ACL (Access Control List) – Real Example:
Path: app/code/MyCompany/Blog/etc/acl.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="MyCompany_Blog::blog" title="Blog" sortOrder="50">
<resource id="MyCompany_Blog::posts" title="Manage Posts" sortOrder="10"/>
<resource id="MyCompany_Blog::config" title="Blog Configuration" sortOrder="20"/>
</resource>
</resource>
</resources>
</acl>
</config>
In controllers, use:
const ADMIN_RESOURCE = 'MyCompany_Blog::posts';
Chapter 7: Core Extension Methods (Plugins, Preferences, Observers)
This chapter covers the three primary ways to extend Magento without modifying core code.
7.1 Dependency Injection – Full Deep Dive
7.1.1 What is Dependency Injection?
Dependency Injection (DI) is a design pattern where a class receives its dependencies from an external source (the DI container) rather than creating them internally. In Magento, the DI container automatically instantiates and injects required objects based on type declarations in constructors.
Why DI is mandatory in Magento:
- Testability: You can substitute mock objects during unit testing
- Decoupling: Classes depend on interfaces, not concrete implementations
- Flexibility: The same interface can have different implementations for different areas
- No hardcoded
new: Magento’s object manager never requires you to writenew ClassName()
7.1.2 Constructor Injection – The Only Injection Method
Magento supports only constructor injection. Every dependency must be declared in the constructor and assigned to a private readonly property.
<?php
// File: app/code/MyCompany/Blog/Model/BlogPostRepository.php
namespace MyCompany\Blog\Model;
use MyCompany\Blog\Api\BlogPostRepositoryInterface;
use MyCompany\Blog\Model\ResourceModel\BlogPost as BlogPostResource;
use MyCompany\Blog\Model\BlogPostFactory;
use Psr\Log\LoggerInterface;
class BlogPostRepository implements BlogPostRepositoryInterface
{
public function __construct(
private readonly BlogPostResource $resource,
private readonly BlogPostFactory $blogPostFactory,
private readonly LoggerInterface $logger
) {}
public function getById(int $postId): BlogPostInterface
{
$this->logger->info('Fetching post: ' . $postId);
$post = $this->blogPostFactory->create();
$this->resource->load($post, $postId);
return $post;
}
}
7.1.3 di.xml – The Configuration File for DI
Preferences (Interface to Class Mapping):
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="MyCompany\Blog\Api\BlogPostRepositoryInterface"
type="MyCompany\Blog\Model\BlogPostRepository"/>
</config>
Virtual Types:
<virtualType name="MyCompany\Blog\Model\SpecialLogger" type="Magento\Framework\Logger\Monolog">
<arguments>
<argument name="name" xsi:type="string">blog_special</argument>
</arguments>
</virtualType>
Argument Injection (Overriding Constructor Arguments):
<type name="MyCompany\Blog\Model\BlogPostRepository">
<arguments>
<argument name="logger" xsi:type="object">MyCompany\Blog\Model\SpecialLogger</argument>
</arguments>
</type>
7.1.4 Object Manager – Never Use Directly
Incorrect (do NOT do):
$product = \Magento\Framework\App\ObjectManager::getInstance()
->get(\Magento\Catalog\Model\Product::class);
Correct:
public function __construct(
private \Magento\Catalog\Model\ProductFactory $productFactory
) {}
public function someMethod() {
$product = $this->productFactory->create();
}
7.1.5 How DI Works Internally
- Magento reads all di.xml files from every module and merges them
- It builds a dependency graph – for each class, it determines what arguments are needed
- For each interface, it finds the preference (concrete class)
- When a class is instantiated, the DI container recursively creates all its dependencies
- The generated code is cached in
generated/(factories, interceptors) - To regenerate DI configuration:
php bin/magento setup:di:compile
7.2 Plugins (Interceptors)
7.2.1 What is a Plugin?
A plugin (also called an interceptor) is a class that intercepts public methods of any Magento class to modify arguments, return values, or add behaviour before, after, or around the original method.
Limitations: You cannot create plugins on:
finalmethods orfinalclasses- Static methods
- Constructors (
__construct) - Non-public methods
7.2.2 Three Types of Plugin Methods
| Type | Method Naming | When it Runs | Typical Use |
|---|---|---|---|
| Before | beforeMethodName() | Before the original method | Validate or modify arguments |
| After | afterMethodName() | After the original method | Modify the return value |
| Around | aroundMethodName() | Wraps the original method | Add logic before AND after |
7.2.3 Real Example: Plugin on Product::getName() and setName()
Step 1 – Create the plugin class:
Path: app/code/MyCompany/Blog/Plugin/Catalog/Model/ProductPlugin.php
<?php
namespace MyCompany\Blog\Plugin\Catalog\Model;
use Magento\Catalog\Model\Product;
class ProductPlugin
{
public function beforeSetName(Product $subject, string $name): array
{
$cleaned = ucfirst(trim($name));
return [$cleaned];
}
public function afterGetName(Product $subject, string $result): string
{
if ($subject->getSpecialPrice()) {
return $result . ' (SALE)';
}
return $result;
}
public function aroundSave(Product $subject, callable $proceed): Product
{
$oldSku = $subject->getOrigData('sku');
$result = $proceed();
$newSku = $subject->getSku();
return $result;
}
}
Step 2 – Register the plugin in di.xml:
Path: app/code/MyCompany/Blog/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Product">
<plugin name="mycompany_blog_product_plugin"
type="MyCompany\Blog\Plugin\Catalog\Model\ProductPlugin"
sortOrder="10"
disabled="false"/>
</type>
</config>
7.2.4 Important Rules for Plugins
beforemethods: Must return an array of arguments with the same count as the original methodaftermethods: Must return a value (the modified result)aroundmethods: Must acceptcallable $proceedas the second argument; you must call$proceed()to execute the original method
7.2.5 Real Use Case: Validating Product SKU
public function beforeSetSku(Product $subject, string $sku): array
{
if (strlen($sku) < 3) {
throw new \InvalidArgumentException('SKU must be at least 3 characters');
}
return [strtoupper($sku)];
}
7.3 Preferences – Class Overrides
7.3.1 Definition
A preference completely replaces one class with another. Magento will use your class everywhere the original class is requested.
Syntax in di.xml:
<preference for="Original\Class\Name" type="MyCompany\Blog\Model\MyOverride"/>
7.3.2 When to Use Preferences (Rarely)
Preferences should be your last resort because only one preference can exist per class. If two modules both set a preference for the same class, they conflict.
Appropriate use cases:
- You need to add a new method to a class (plugins cannot add methods)
- You need to change the constructor signature
- The class is not designed to be extended via plugins
7.3.3 Real Example – Adding a New Method to Product
Path: app/code/MyCompany/Blog/Model/Catalog/Product.php
<?php
namespace MyCompany\Blog\Model\Catalog;
use Magento\Catalog\Model\Product as CoreProduct;
class Product extends CoreProduct
{
public function getFormattedSku(): string
{
return 'SKU-' . strtoupper($this->getSku());
}
}
Register the preference:
<preference for="Magento\Catalog\Model\Product"
type="MyCompany\Blog\Model\Catalog\Product"/>
7.3.4 Warning: Preferences Break if Overridden
If another module also has a preference for Magento\Catalog\Model\Product, only one will take effect. Magento does not merge preferences; the last processed di.xml wins.
7.4 Observers & Events
7.4.1 Definition
An event is a point in Magento’s execution that dispatches a named signal. An observer is a class that listens for a specific event and executes custom code when that event occurs.
Why use observers:
- They do not modify input or output (unlike plugins). They are side-effect only
- Many modules can listen to the same event
- Useful for logging, sending notifications, updating external systems
7.4.2 Registering an Observer in events.xml
Path: app/code/MyCompany/Blog/etc/events.xml (global)
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="catalog_product_save_after">
<observer name="mycompany_blog_log_product_save"
instance="MyCompany\Blog\Observer\LogProductSaveObserver"
disabled="false"/>
</event>
</config>
7.4.3 Creating an Observer Class
Path: app/code/MyCompany/Blog/Observer/LogProductSaveObserver.php
<?php
namespace MyCompany\Blog\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Psr\Log\LoggerInterface;
class LogProductSaveObserver implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {}
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
$this->logger->info('Product saved: ' . $product->getSku());
}
}
7.4.4 Dispatching Your Own Custom Event
public function __construct(
private readonly \Magento\Framework\Event\ManagerInterface $eventManager
) {}
public function publishPost(int $postId, array $postData): void
{
$this->eventManager->dispatch(
'mycompany_blog_post_published',
['post_id' => $postId, 'post_data' => $postData]
);
}
Observer for custom event:
<event name="mycompany_blog_post_published">
<observer name="mycompany_blog_send_notification"
instance="MyCompany\Blog\Observer\SendPostNotificationObserver"/>
</event>
7.4.5 List of Common Core Events
| Event Name | When Dispatched | Available Data |
|---|---|---|
| catalog_product_save_after | After product saved | product |
| checkout_cart_add_product_complete | After product added to cart | product |
| sales_order_place_after | After order placed | order |
| customer_register_success | After customer registration | customer |
| controller_action_predispatch | Before any controller action | controller_action |
| cms_page_render | Before rendering CMS page | page |
7.5 Comparison & Decision Guide: Plugin vs. Preference vs. Observer
| What you need to do | Best solution | Why |
|---|---|---|
| Modify arguments before a method is called | Plugin (before) | Clean, multiple modules can coexist |
| Modify the return value of a method | Plugin (after) | Clean, does not break other modules |
| Add behaviour both before and after a method | Plugin (around) | Use sparingly |
| Add a completely new method to a class | Preference | Plugins cannot add methods |
| Change the constructor signature | Preference or virtual type | Virtual type is often better |
| React to something that happened | Observer | Does not modify flow; side-effect only |
Decision Flowchart:
Start: What do you need?
│
├─ Add behaviour before/after a method? → Plugin (before/after)
├─ Completely wrap a method? → Plugin (around)
├─ Add a new method to a class? → Preference
├─ Change constructor arguments? → Virtual type (or preference)
├─ React to an event (order placed, product saved)? → Observer
└─ None of the above? → You may not need to extend.
Real-World Example: Which to Use?
- Scenario 1: Log every time a product is viewed → Observer listening to
catalog_product_viewevent - Scenario 2: Force product names to be uppercase before saving → Plugin with
beforeSetName() - Scenario 3: Add a method
getStockAlert()to the product model → Preference - Scenario 4: Send an email after an order is placed → Observer on
sales_order_place_after - Scenario 5: Modify the price calculation logic entirely → Plugin on
getPrice()(after plugin)
Chapter 8: Intermediate Backend Development
8.1 Routing & Controllers (Frontend & Admin)
8.1.1 Frontend Routing
Path: app/code/MyCompany/Blog/etc/frontend/routes.xml
<?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="MyCompany_Blog"/>
</route>
</router>
</config>
8.1.2 Frontend Controller
Path: app/code/MyCompany/Blog/Controller/Index/Index.php
<?php
namespace MyCompany\Blog\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
private PageFactory $resultPageFactory;
public function __construct(PageFactory $resultPageFactory)
{
$this->resultPageFactory = $resultPageFactory;
}
public function execute()
{
return $this->resultPageFactory->create();
}
}
8.1.3 Adminhtml Routing
Path: app/code/MyCompany/Blog/etc/adminhtml/routes.xml
<?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="admin">
<route id="mycompany_blog" frontName="mycompany_blog">
<module name="MyCompany_Blog" before="Magento_Backend"/>
</route>
</router>
</config>
Admin Controller:
Path: app/code/MyCompany/Blog/Controller/Adminhtml/Post/Index.php
<?php
namespace MyCompany\Blog\Controller\Adminhtml\Post;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Blog::posts';
private PageFactory $resultPageFactory;
public function __construct(Context $context, PageFactory $resultPageFactory)
{
parent::__construct($context);
$this->resultPageFactory = $resultPageFactory;
}
public function execute()
{
$resultPage = $this->resultPageFactory->create();
$resultPage->setActiveMenu('MyCompany_Blog::blog');
$resultPage->getConfig()->getTitle()->prepend(__('Manage Blog Posts'));
return $resultPage;
}
}
8.2 Blocks and Templates (PHTML)
8.2.1 Creating a Block
Path: app/code/MyCompany/Blog/Block/PostList.php
<?php
namespace MyCompany\Blog\Block;
use Magento\Framework\View\Element\Template;
use MyCompany\Blog\Api\BlogPostRepositoryInterface;
class PostList extends Template
{
private BlogPostRepositoryInterface $postRepository;
public function __construct(
Template\Context $context,
BlogPostRepositoryInterface $postRepository,
array $data = []
) {
parent::__construct($context, $data);
$this->postRepository = $postRepository;
}
public function getPosts()
{
return $this->postRepository->getList();
}
}
8.2.2 Creating a Template
Path: app/code/MyCompany/Blog/view/frontend/templates/post_list.phtml
<?php
/** @var \MyCompany\Blog\Block\PostList $block */
$posts = $block->getPosts();
?>
<div class="blog-post-list">
<h1><?= __('Blog Posts') ?></h1>
<?php if (count($posts)): ?>
<?php foreach ($posts as $post): ?>
<article>
<h2><?= $escaper->escapeHtml($post->getTitle()) ?></h2>
<div class="content"><?= $post->getContent() ?></div>
</article>
<?php endforeach; ?>
<?php else: ?>
<p><?= __('No posts found.') ?></p>
<?php endif; ?>
</div>
8.2.3 Associating Block and Template via Layout
Path: app/code/MyCompany/Blog/view/frontend/layout/blog_index_index.xml
<?xml version="1.0"?>
<page layout="2columns-left" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<block class="MyCompany\Blog\Block\PostList"
name="blog.post.list"
template="MyCompany_Blog::post_list.phtml"/>
</referenceContainer>
</body>
</page>
8.3 UI Components for Admin Grids and Forms
8.3.1 Admin Grid UI Component Example
Path: app/code/MyCompany/Blog/view/adminhtml/ui_component/mycompany_blog_post_listing.xml
<?xml version="1.0" encoding="UTF-8"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="provider" xsi:type="string">
mycompany_blog_post_listing.mycompany_blog_post_listing_data_source
</item>
</item>
</argument>
<settings>
<buttons>
<button name="add">
<url path="mycompany_blog/post/new"/>
<class>primary</class>
<label translate="true">Add New Post</label>
</button>
</buttons>
<spinner>mycompany_blog_post_columns</spinner>
<deps>
<dep>mycompany_blog_post_listing.mycompany_blog_post_listing_data_source</dep>
</deps>
</settings>
<dataSource name="mycompany_blog_post_listing_data_source" component="Magento_Ui/js/grid/provider">
<settings>
<storageConfig>
<param name="indexField" xsi:type="string">post_id</param>
</storageConfig>
<updateUrl path="mui/index/render"/>
</settings>
<aclResource>MyCompany_Blog::posts</aclResource>
<dataProvider class="Magento\Framework\View\Element\UiComponent\DataProvider\DataProvider"
name="mycompany_blog_post_listing_data_source">
<settings>
<requestFieldName>id</requestFieldName>
<primaryFieldName>post_id</primaryFieldName>
</settings>
</dataProvider>
</dataSource>
<columns name="mycompany_blog_post_columns">
<selectionsColumn name="ids">
<settings>
<indexField>post_id</indexField>
</settings>
</selectionsColumn>
<column name="post_id">
<settings>
<filter>textRange</filter>
<label translate="true">ID</label>
<sorting>asc</sorting>
</settings>
</column>
<column name="title">
<settings>
<filter>text</filter>
<label translate="true">Title</label>
</settings>
</column>
<column name="is_active" component="Magento_Ui/js/grid/columns/select">
<settings>
<options class="Magento\Config\Model\Config\Source\Yesno"/>
<filter>select</filter>
<label translate="true">Active</label>
</settings>
</column>
<actionsColumn name="actions" class="MyCompany\Blog\Ui\Component\Listing\Column\PostActions">
<settings>
<indexField>post_id</indexField>
</settings>
</actionsColumn>
</columns>
</listing>
8.3.2 Actions Column Class
Path: app/code/MyCompany/Blog/Ui/Component/Listing/Column/PostActions.php
<?php
namespace MyCompany\Blog\Ui\Component\Listing\Column;
use Magento\Framework\View\Element\UiComponent\ContextInterface;
use Magento\Framework\View\Element\UiComponentFactory;
use Magento\Ui\Component\Listing\Columns\Column;
class PostActions extends Column
{
public function prepareDataSource(array $dataSource)
{
if (isset($dataSource['data']['items'])) {
foreach ($dataSource['data']['items'] as &$item) {
$item[$this->getData('name')] = [
'edit' => [
'href' => $this->getContext()->getUrl('mycompany_blog/post/edit', ['post_id' => $item['post_id']]),
'label' => __('Edit')
],
'delete' => [
'href' => $this->getContext()->getUrl('mycompany_blog/post/delete', ['post_id' => $item['post_id']]),
'label' => __('Delete'),
'confirm' => ['title' => __('Delete'), 'message' => __('Are you sure?')]
]
];
}
}
return $dataSource;
}
}
8.4 Creating a Complete CRUD Module (Blog Module)
8.4.1 Module Registration (Quick Recap)
Path: app/code/MyCompany/Blog/registration.php
Path: app/code/MyCompany/Blog/etc/module.xml (with setup_version="1.0.0")
8.4.2 Database Schema (Declarative Schema)
Path: app/code/MyCompany/Blog/etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mycompany_blog_post" resource="default" engine="innodb">
<column xsi:type="int" name="post_id" padding="10" unsigned="true" nullable="false" identity="true"/>
<column xsi:type="varchar" name="title" nullable="false" length="255"/>
<column xsi:type="text" name="content" nullable="false"/>
<column xsi:type="smallint" name="is_active" nullable="false" default="1"/>
<column xsi:type="timestamp" name="created_at" nullable="false" default="CURRENT_TIMESTAMP"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="post_id"/>
</constraint>
</table>
</schema>
Run php bin/magento setup:upgrade to create the table.
8.4.3 Data Interface (Service Contract)
Path: app/code/MyCompany/Blog/Api/Data/BlogPostInterface.php
<?php
namespace MyCompany\Blog\Api\Data;
interface BlogPostInterface
{
const POST_ID = 'post_id';
const TITLE = 'title';
const CONTENT = 'content';
const IS_ACTIVE = 'is_active';
const CREATED_AT = 'created_at';
public function getPostId(): ?int;
public function setPostId(int $postId): self;
public function getTitle(): ?string;
public function setTitle(string $title): self;
public function getContent(): ?string;
public function setContent(string $content): self;
public function isActive(): bool;
public function setIsActive(bool $isActive): self;
public function getCreatedAt(): ?string;
public function setCreatedAt(string $createdAt): self;
}
8.4.4 Model
Path: app/code/MyCompany/Blog/Model/BlogPost.php
<?php
namespace MyCompany\Blog\Model;
use Magento\Framework\Model\AbstractModel;
use MyCompany\Blog\Api\Data\BlogPostInterface;
class BlogPost extends AbstractModel implements BlogPostInterface
{
protected function _construct()
{
$this->_init(\MyCompany\Blog\Model\ResourceModel\BlogPost::class);
}
public function getPostId(): ?int { return $this->getData(self::POST_ID) ? (int)$this->getData(self::POST_ID) : null; }
public function setPostId(int $postId): self { return $this->setData(self::POST_ID, $postId); }
public function getTitle(): ?string { return $this->getData(self::TITLE); }
public function setTitle(string $title): self { return $this->setData(self::TITLE, $title); }
public function getContent(): ?string { return $this->getData(self::CONTENT); }
public function setContent(string $content): self { return $this->setData(self::CONTENT, $content); }
public function isActive(): bool { return (bool)$this->getData(self::IS_ACTIVE); }
public function setIsActive(bool $isActive): self { return $this->setData(self::IS_ACTIVE, (int)$isActive); }
public function getCreatedAt(): ?string { return $this->getData(self::CREATED_AT); }
public function setCreatedAt(string $createdAt): self { return $this->setData(self::CREATED_AT, $createdAt); }
}
8.4.5 Resource Model
Path: app/code/MyCompany/Blog/Model/ResourceModel/BlogPost.php
<?php
namespace MyCompany\Blog\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class BlogPost extends AbstractDb
{
protected function _construct()
{
$this->_init('mycompany_blog_post', 'post_id');
}
}
8.4.6 Collection
Path: app/code/MyCompany/Blog/Model/ResourceModel/BlogPost/Collection.php
<?php
namespace MyCompany\Blog\Model\ResourceModel\BlogPost;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
class Collection extends AbstractCollection
{
protected function _construct()
{
$this->_init(\MyCompany\Blog\Model\BlogPost::class, \MyCompany\Blog\Model\ResourceModel\BlogPost::class);
}
}
8.4.7 Repository Interface
Path: app/code/MyCompany/Blog/Api/BlogPostRepositoryInterface.php
<?php
namespace MyCompany\Blog\Api;
use MyCompany\Blog\Api\Data\BlogPostInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
interface BlogPostRepositoryInterface
{
public function getById(int $postId): BlogPostInterface;
public function save(BlogPostInterface $post): BlogPostInterface;
public function delete(BlogPostInterface $post): bool;
public function deleteById(int $postId): bool;
public function getList(SearchCriteriaInterface $searchCriteria): \Magento\Framework\Api\SearchResultsInterface;
}
8.4.8 Repository Implementation
Path: app/code/MyCompany/Blog/Model/BlogPostRepository.php
<?php
namespace MyCompany\Blog\Model;
use MyCompany\Blog\Api\BlogPostRepositoryInterface;
use MyCompany\Blog\Api\Data\BlogPostInterface;
use MyCompany\Blog\Model\ResourceModel\BlogPost as BlogPostResource;
use MyCompany\Blog\Model\ResourceModel\BlogPost\CollectionFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Api\SearchResultsInterfaceFactory;
use Magento\Framework\Exception\NoSuchEntityException;
class BlogPostRepository implements BlogPostRepositoryInterface
{
private BlogPostFactory $blogPostFactory;
private BlogPostResource $resource;
private CollectionFactory $collectionFactory;
private SearchResultsInterfaceFactory $searchResultsFactory;
public function __construct(
BlogPostFactory $blogPostFactory,
BlogPostResource $resource,
CollectionFactory $collectionFactory,
SearchResultsInterfaceFactory $searchResultsFactory
) {
$this->blogPostFactory = $blogPostFactory;
$this->resource = $resource;
$this->collectionFactory = $collectionFactory;
$this->searchResultsFactory = $searchResultsFactory;
}
public function getById(int $postId): BlogPostInterface
{
$post = $this->blogPostFactory->create();
$this->resource->load($post, $postId);
if (!$post->getId()) {
throw new NoSuchEntityException(__('Blog post with id %1 does not exist', $postId));
}
return $post;
}
public function save(BlogPostInterface $post): BlogPostInterface
{
$this->resource->save($post);
return $post;
}
public function delete(BlogPostInterface $post): bool
{
$this->resource->delete($post);
return true;
}
public function deleteById(int $postId): bool
{
return $this->delete($this->getById($postId));
}
public function getList(SearchCriteriaInterface $searchCriteria)
{
$collection = $this->collectionFactory->create();
// Apply filters from search criteria (simplified)
$searchResults = $this->searchResultsFactory->create();
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
}
8.4.9 di.xml – Preferences
Path: app/code/MyCompany/Blog/etc/di.xml
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="MyCompany\Blog\Api\BlogPostRepositoryInterface"
type="MyCompany\Blog\Model\BlogPostRepository"/>
<preference for="MyCompany\Blog\Api\Data\BlogPostInterface"
type="MyCompany\Blog\Model\BlogPost"/>
</config>
8.4.10 REST API (webapi.xml)
Path: app/code/MyCompany/Blog/etc/webapi.xml
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/blog/posts/:postId" method="GET">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="getById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts" method="GET">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="getList"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts" method="POST">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="save"/>
<resources>
<resource ref="Magento_Backend::admin"/>
</resources>
</route>
<route url="/V1/blog/posts/:postId" method="DELETE">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="Magento_Backend::admin"/>
</resources>
</route>
</routes>
8.4.11 Admin Grid UI Component
Refer to Section 8.3 for the complete XML.
8.4.12 Frontend Listing Page
- Controller:
app/code/MyCompany/Blog/Controller/Index/Index.php(as in 8.1.2) - Layout:
view/frontend/layout/blog_index_index.xml(as in 8.2.3) - Block:
Block/PostList.php(as in 8.2.1) - Template:
view/frontend/templates/post_list.phtml(as in 8.2.2)
8.5 Frontend Styling (LESS, CSS, SCSS) & Layout System Deep Dive
8.5.1 Adding CSS/LESS in a Module
Path: app/code/MyCompany/Blog/view/frontend/web/css/blog.css
.blog-post-list article {
border-bottom: 1px solid #ccc;
margin-bottom: 20px;
padding-bottom: 10px;
}
Include in layout:
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="MyCompany_Blog::css/blog.css"/>
</head>
<!-- ... rest of layout ... -->
</page>
8.5.2 Layout System Deep Dive
- Handles: Every page has a set of layout handles (e.g.,
catalog_product_view,cms_index_index) - Containers: Structural elements (e.g.,
content,sidebar.main) that contain blocks - Blocks: Functional elements that generate HTML
- Merging: Magento merges all layout XML files from all modules and themes
Common layout instructions:
<remove name="block.name"/>– removes a block<move element="block.name" destination="new.container" after="-"/>– moves a block<referenceBlock name="block.name" remove="true"/>— An alternative way to remove a block from the layout.
8.6 Magento Widgets, Catalog Management, Customer Management, Sales & Checkout Flow
8.6.1 Magento Widgets
Widgets are reusable content blocks that can be inserted via the admin panel.
Widget XML example:
Path: app/code/MyCompany/Blog/etc/widget.xml
<?xml version="1.0"?>
<widgets xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Widget:etc/widget.xsd">
<widget id="blog_latest_posts" class="MyCompany\Blog\Block\Widget\LatestPosts">
<label translate="true">Latest Blog Posts</label>
<description>Show a list of recent blog posts</description>
<parameters>
<parameter name="title" xsi:type="text" required="false" visible="true">
<label translate="true">Title</label>
</parameter>
<parameter name="number_of_posts" xsi:type="text" required="true" visible="true">
<label translate="true">Number of Posts</label>
<value>5</value>
</parameter>
</parameters>
</widget>
</widgets>
8.6.2 Catalog Management
- Products: Use
ProductRepositoryInterfaceto load, save, delete. UseProductFactoryto create new ones. - Categories: Use
CategoryRepositoryInterface.
Example – Load product by SKU:
$product = $productRepository->get('sku123');
$product->setPrice(49.99);
$productRepository->save($product);
8.6.3 Customer Management
- Customer Repository:
CustomerRepositoryInterfaceto load/save customers. - Customer Account: Use
AccountManagementInterfacefor registration, login, password reset.
Example – Load customer by email:
$customer = $customerRepository->get('customer@example.com');
8.6.4 Sales & Checkout Flow
- Quote: Temporary shopping cart (
Magento\Quote\Api\CartRepositoryInterface) - Order: After checkout, the quote is converted to an order (
Magento\Sales\Api\OrderRepositoryInterface)
Example – Get current quote:
$cart = $cartRepository->get($customerId);
Chapter 9: Models, Resource Models, Collections, Blocks & Complete CRUD Module
9.1 Understanding the Magento Data Layer — Why Three Separate Classes?
Before writing any code, you must understand why Magento splits data handling into three separate classes (Model, Resource Model, Collection) instead of one class doing everything like in simpler frameworks.
The problem it solves: Imagine if your Item class had to know how to talk to MySQL directly — building SQL queries, opening database connections, handling transactions. Now imagine your business logic (validating an item, calculating its price) was mixed into that same class. If you ever wanted to switch from MySQL to a different storage system, or if you wanted to unit test your business logic without touching a real database, you’d be stuck — everything is tangled together.
Magento solves this by giving each responsibility its own class:
| Class | Responsibility | Analogy |
|---|---|---|
| Model | Represents ONE business entity and its data/behaviour | The actual product itself — its name, price, the rules about what makes a valid product |
| Resource Model | Handles HOW that one entity is saved/loaded/deleted from the database | The warehouse worker who physically moves the product in and out of storage |
| Collection | Handles loading and filtering MULTIPLE entities at once | The forklift that grabs a whole pallet of products matching certain criteria |
9.2 The Model — Deep Explanation
9.2.1 What a Model Actually Is
A Model in Magento represents a single instance of a business entity — one product, one customer, one order, one custom “Item” in your own module. It extends Magento\Framework\Model\AbstractModel, which itself extends Magento\Framework\DataObject.
Why it extends DataObject: DataObject is Magento’s generic “bag of data” class. It stores all attributes in a single internal PHP array called $_data, rather than requiring you to declare a PHP property for every single field.
// Inside DataObject (simplified, for understanding)
class DataObject
{
protected $_data = []; // everything lives in here
public function setData($key, $value = null)
{
$this->_data[$key] = $value;
return $this;
}
public function getData($key = '')
{
if ($key === '') {
return $this->_data;
}
return $this->_data[$key] ?? null;
}
}
9.2.2 What the Model Is Responsible For
- Holding the data of ONE entity (one row, conceptually)
- Defining business logic/validation specific to that entity
- Knowing which Resource Model to delegate actual database operations to
- Firing events before/after load, save, and delete
9.2.3 What the Model Is NOT Responsible For
- Writing SQL queries
- Knowing the database table name or column names directly
- Filtering/searching multiple records (that’s the Collection’s job)
9.2.4 Anatomy of a Model Class — Every Line Explained
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Model;
use Magento\Framework\Model\AbstractModel;
use MyCompany\HelloWorld\Model\ResourceModel\Item as ItemResource;
class Item extends AbstractModel
{
protected function _construct()
{
$this->_init(ItemResource::class);
}
}
Line-by-line breakdown:
protected function _construct()— This is NOT the PHP constructor__construct(). It’s a Magento-specific convention called afterAbstractModel‘s real constructor finishes$this->_init(ItemResource::class);— Tells the Model “when someone callssave(),load(), ordelete()on me, delegate that work to theItemResourceResource Model class”
9.2.5 How the Model’s Inherited Methods Actually Work
When you write $item->load(5), here is what happens internally:
load()is a method defined inAbstractModel(inherited)AbstractModel::load($id)calls$this->getResource()->load($this, $id)getResource()returns an instance ofItemResource- The Resource Model’s
load()method runs the actual SQLSELECTquery - The Resource Model populates the
$itemobject with the row’s data - Control returns to your code, and
$itemnow holds the loaded data
9.2.6 Adding Custom Business Logic to a Model
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Model;
use Magento\Framework\Model\AbstractModel;
use Magento\Framework\Exception\LocalizedException;
use MyCompany\HelloWorld\Model\ResourceModel\Item as ItemResource;
class Item extends AbstractModel
{
protected function _construct()
{
$this->_init(ItemResource::class);
}
public function beforeSave()
{
$name = (string) $this->getData('name');
if (strlen(trim($name)) < 3) {
throw new LocalizedException(
__('Item name must be at least 3 characters long.')
);
}
return parent::beforeSave();
}
public function getDisplayName(): string
{
$name = (string) $this->getData('name');
$status = (string) $this->getData('status');
return $status === 'active' ? $name : $name . ' (Inactive)';
}
}
9.3 The Resource Model — Deep Explanation
9.3.1 What a Resource Model Actually Is
The Resource Model is the only class in your entire module that is allowed to know about database specifics: the table name, the primary key column name, and custom SQL. It extends Magento\Framework\Model\ResourceModel\Db\AbstractDb.
9.3.2 What the Resource Model Is Responsible For
- Knowing the table name and primary key column
- Executing actual SQL: SELECT (load), INSERT/UPDATE (save), DELETE
- Managing database connections and adapters
9.3.3 What the Resource Model Is NOT Responsible For
- Business validation (that’s the Model’s
beforeSave()) - Deciding what data is “correct”
- Loading MULTIPLE rows at once (that’s the Collection’s job)
9.3.4 Anatomy of a Resource Model Class — Every Line Explained
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Item extends AbstractDb
{
protected function _construct()
{
$this->_init('mycompany_helloworld_item', 'item_id');
}
}
Line-by-line breakdown:
$this->_init('mycompany_helloworld_item', 'item_id');— The first argument is the literal database table name, the second is the name of the primary key column
9.3.5 What Happens Internally When _init() Is Called
When you call $resource->load($itemModel, 5):
SELECT * FROM `mycompany_helloworld_item` WHERE `item_id` = 5
When you call $resource->save($itemModel) on a NEW item:
INSERT INTO `mycompany_helloworld_item` (`name`, `description`, `status`, `created_at`)
VALUES ('Test Item', 'A description', 'active', '2026-06-21 10:00:00')
9.3.6 Adding Custom Database Logic to a Resource Model
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class Item extends AbstractDb
{
protected function _construct()
{
$this->_init('mycompany_helloworld_item', 'item_id');
}
public function bulkDeactivateByCategory(int $categoryId): int
{
$connection = $this->getConnection();
$tableName = $this->getMainTable();
return $connection->update(
$tableName,
['status' => 'inactive'],
['category_id = ?' => $categoryId]
);
}
public function countActiveItems(): int
{
$connection = $this->getConnection();
$select = $connection->select()
->from($this->getMainTable(), ['count' => new \Zend_Db_Expr('COUNT(*)')])
->where('status = ?', 'active');
return (int) $connection->fetchOne($select);
}
}
9.4 The Collection — Deep Explanation
9.4.1 What a Collection Actually Is
A Collection represents a set of MULTIPLE Model instances, typically the result of a filtered/sorted/paginated database query. It extends Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection, and implements PHP’s Iterator interface.
9.4.2 What the Collection Is Responsible For
- Building a SELECT query with WHERE, ORDER BY, LIMIT clauses
- Executing that query and converting EACH row into a fully-formed Model object
- Providing iteration (
foreach), counting (getSize()), and pagination
9.4.3 What the Collection Is NOT Responsible For
- Validating data (that’s the Model’s job)
- Knowing how to save/delete a single entity (that’s the Resource Model’s job)
9.4.4 Anatomy of a Collection Class — Every Line Explained
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Model\ResourceModel\Item;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use MyCompany\HelloWorld\Model\Item as ItemModel;
use MyCompany\HelloWorld\Model\ResourceModel\Item as ItemResourceModel;
class Collection extends AbstractCollection
{
protected function _construct()
{
$this->_init(ItemModel::class, ItemResourceModel::class);
}
}
Line-by-line breakdown:
- The Collection lives ONE LEVEL DEEPER than the Resource Model — inside a folder named after the Resource Model itself
$this->_init(ItemModel::class, ItemResourceModel::class);— First argument tells the Collection what Model class to wrap rows in; second tells it which Resource Model to use
9.4.5 How Collection Methods Work Internally
$collection = $this->itemCollectionFactory->create();
$collection->addFieldToFilter('status', 'active')
->setOrder('name', 'ASC')
->setPageSize(10)
->setCurPage(1);
foreach ($collection as $item) {
echo $item->getName();
}
- Each method call modifies an in-memory
Selectobject - The actual SQL query is only executed when you first try to use the data
9.4.6 Common Collection Filter Methods Explained
// Equality filter
$collection->addFieldToFilter('status', 'active');
// Comparison operators
$collection->addFieldToFilter('price', ['gt' => 100]); // price > 100
$collection->addFieldToFilter('price', ['lt' => 50]); // price < 50
// LIKE pattern matching
$collection->addFieldToFilter('name', ['like' => '%shirt%']);
// IN clause
$collection->addFieldToFilter('status', ['in' => ['active', 'pending']]);
// OR condition on the SAME call
$collection->addFieldToFilter(
['status', 'status'],
[['eq' => 'active'], ['eq' => 'pending']]
);
9.5 The Block — Deep Explanation
9.5.1 What a Block Actually Is
A Block is a PHP class whose sole purpose is to prepare data for a frontend (or admin) template (.phtml file) to display. It extends Magento\Framework\View\Element\Template.
9.5.2 What the Block Is Responsible For
- Fetching the data needed by a template
- Performing display-specific transformations
- Providing URLs for links (
getUrl()) - Exposing methods that the .phtml template calls
9.5.3 What the Block Is NOT Responsible For
- Database queries directly
- Business validation
- Containing raw HTML
9.5.4 Anatomy of a Block Class — Every Line Explained
<?php
declare(strict_types=1);
namespace MyCompany\HelloWorld\Block;
use Magento\Framework\View\Element\Template;
use MyCompany\HelloWorld\Api\ItemRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
class ItemList extends Template
{
public function __construct(
Template\Context $context,
private readonly ItemRepositoryInterface $itemRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
array $data = []
) {
parent::__construct($context, $data);
}
public function getItems(): array
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 'active')
->create();
return $this->itemRepository->getList($searchCriteria)->getItems();
}
public function formatDate(string $rawDate): string
{
$dateTime = new \DateTime($rawDate);
return $dateTime->format('F j, Y');
}
}
9.5.5 How the Block Connects to the Template — The Full Chain
- Layout XML → declares which Block class + which template file to use
- ↓
Magento’s Layout system → instantiates the Block class via DI - ↓
Block’stoHtml()method → is called automatically - ↓
toHtml()internally calls_toHtml(), which includes the .phtml file - ↓
Inside the .phtml file, the variable$blockrefers to YOUR Block instance - ↓
The .phtml file calls$block->getItems(),$block->escapeHtml(), etc.
9.5.6 The $escaper Object — Why It Exists
<!-- WRONG — never do this with dynamic/user-controlled data -->
<h2><?= $item->getName() ?></h2>
<!-- CORRECT — always escape -->
<h2><?= $escaper->escapeHtml($item->getName()) ?></h2>
9.6 Complete CRUD Module — Built From Scratch, Every File Explained
This section combines everything above into one fully working module. This module — MyCompany_Inventory — manages a custom “Stock Item” entity with full Create, Read, Update, Delete capability.
9.6.1 The Complete File Map
app/code/MyCompany/Inventory/
├── registration.php
├── composer.json
├── etc/
│ ├── module.xml
│ ├── db_schema.xml
│ ├── di.xml
│ ├── acl.xml
│ ├── webapi.xml
│ ├── frontend/
│ │ └── routes.xml
│ └── adminhtml/
│ ├── routes.xml
│ ├── menu.xml
│ └── system.xml
├── Api/
│ ├── StockItemRepositoryInterface.php
│ └── Data/
│ ├── StockItemInterface.php
│ └── StockItemSearchResultsInterface.php
├── Model/
│ ├── StockItem.php
│ ├── StockItemRepository.php
│ └── ResourceModel/
│ ├── StockItem.php
│ └── StockItem/
│ └── Collection.php
├── Controller/
│ ├── Index/
│ │ └── Index.php
│ └── Adminhtml/
│ └── StockItem/
│ ├── Index.php
│ ├── NewAction.php
│ ├── Edit.php
│ ├── Save.php
│ ├── Delete.php
│ └── MassDelete.php
├── Block/
│ ├── ItemList.php
│ └── Adminhtml/
│ └── StockItem/
│ └── Edit/
│ └── GenericButton.php
├── Ui/
│ └── DataProvider/
│ └── StockItemDataProvider.php
├── view/
│ ├── frontend/
│ │ ├── layout/
│ │ │ └── inventory_index_index.xml
│ │ └── templates/
│ │ └── item_list.phtml
│ └── adminhtml/
│ ├── layout/
│ │ └── mycompany_inventory_stockitem_edit.xml
│ └── ui_component/
│ ├── mycompany_inventory_stockitem_listing.xml
│ └── mycompany_inventory_stockitem_form.xml
9.6.2 Module Registration Files
app/code/MyCompany/Inventory/registration.php:
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::MODULE,
'MyCompany_Inventory',
__DIR__
);
app/code/MyCompany/Inventory/etc/module.xml:
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Module/etc/module.xsd">
<module name="MyCompany_Inventory" setup_version="1.0.0"/>
</config>
9.6.3 Database Schema — etc/db_schema.xml
<?xml version="1.0"?>
<schema xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Setup/Declaration/Schema/etc/schema.xsd">
<table name="mycompany_inventory_stock_item" resource="default" engine="innodb"
comment="Custom Stock Items">
<column xsi:type="int" name="item_id" padding="10" unsigned="true"
nullable="false" identity="true" comment="Item ID"/>
<column xsi:type="varchar" name="name" nullable="false" length="255"
comment="Item Name"/>
<column xsi:type="varchar" name="sku" nullable="false" length="64"
comment="Stock Keeping Unit"/>
<column xsi:type="text" name="description" nullable="true"
comment="Description"/>
<column xsi:type="int" name="quantity" unsigned="true" nullable="false"
default="0" comment="Quantity in Stock"/>
<column xsi:type="decimal" name="unit_price" scale="2" precision="12"
unsigned="true" nullable="false" default="0.00" comment="Unit Price"/>
<column xsi:type="smallint" name="status" unsigned="true" nullable="false"
default="1" comment="Status: 1=Active, 0=Inactive"/>
<column xsi:type="timestamp" name="created_at" nullable="false"
default="CURRENT_TIMESTAMP" comment="Created At"/>
<column xsi:type="timestamp" name="updated_at" nullable="false"
default="CURRENT_TIMESTAMP" on_update="true" comment="Updated At"/>
<constraint xsi:type="primary" referenceId="PRIMARY">
<column name="item_id"/>
</constraint>
<constraint xsi:type="unique" referenceId="MYCOMPANY_INVENTORY_STOCK_ITEM_SKU">
<column name="sku"/>
</constraint>
<index referenceId="MYCOMPANY_INVENTORY_STOCK_ITEM_STATUS" indexType="btree">
<column name="status"/>
</index>
<index referenceId="MYCOMPANY_INVENTORY_STOCK_ITEM_NAME" indexType="btree">
<column name="name"/>
</index>
</table>
</schema>
Apply with: php bin/magento setup:upgrade
9.6.4 The Data Interface (Service Contract) — Api/Data/StockItemInterface.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Api\Data;
interface StockItemInterface
{
const ITEM_ID = 'item_id';
const NAME = 'name';
const SKU = 'sku';
const DESCRIPTION = 'description';
const QUANTITY = 'quantity';
const UNIT_PRICE = 'unit_price';
const STATUS = 'status';
const CREATED_AT = 'created_at';
const UPDATED_AT = 'updated_at';
public function getItemId(): ?int;
public function setItemId(int $itemId): self;
public function getName(): string;
public function setName(string $name): self;
public function getSku(): string;
public function setSku(string $sku): self;
public function getDescription(): ?string;
public function setDescription(?string $description): self;
public function getQuantity(): int;
public function setQuantity(int $quantity): self;
public function getUnitPrice(): float;
public function setUnitPrice(float $unitPrice): self;
public function getStatus(): int;
public function setStatus(int $status): self;
public function getCreatedAt(): ?string;
public function setCreatedAt(string $createdAt): self;
}
9.6.5 The Model Implementing the Interface — Model/StockItem.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Model;
use Magento\Framework\Model\AbstractModel;
use Magento\Framework\Exception\LocalizedException;
use MyCompany\Inventory\Api\Data\StockItemInterface;
use MyCompany\Inventory\Model\ResourceModel\StockItem as StockItemResource;
class StockItem extends AbstractModel implements StockItemInterface
{
protected function _construct()
{
$this->_init(StockItemResource::class);
}
public function beforeSave()
{
if (trim((string) $this->getName()) === '') {
throw new LocalizedException(__('Item name cannot be empty.'));
}
if (trim((string) $this->getSku()) === '') {
throw new LocalizedException(__('SKU cannot be empty.'));
}
if ($this->getQuantity() < 0) {
throw new LocalizedException(__('Quantity cannot be negative.'));
}
if ($this->getUnitPrice() < 0) {
throw new LocalizedException(__('Unit price cannot be negative.'));
}
return parent::beforeSave();
}
public function getItemId(): ?int
{
$value = $this->getData(self::ITEM_ID);
return $value !== null ? (int) $value : null;
}
public function setItemId(int $itemId): self
{
return $this->setData(self::ITEM_ID, $itemId);
}
public function getName(): string
{
return (string) $this->getData(self::NAME);
}
public function setName(string $name): self
{
return $this->setData(self::NAME, $name);
}
public function getSku(): string
{
return (string) $this->getData(self::SKU);
}
public function setSku(string $sku): self
{
return $this->setData(self::SKU, $sku);
}
public function getDescription(): ?string
{
return $this->getData(self::DESCRIPTION);
}
public function setDescription(?string $description): self
{
return $this->setData(self::DESCRIPTION, $description);
}
public function getQuantity(): int
{
return (int) $this->getData(self::QUANTITY);
}
public function setQuantity(int $quantity): self
{
return $this->setData(self::QUANTITY, $quantity);
}
public function getUnitPrice(): float
{
return (float) $this->getData(self::UNIT_PRICE);
}
public function setUnitPrice(float $unitPrice): self
{
return $this->setData(self::UNIT_PRICE, $unitPrice);
}
public function getStatus(): int
{
return (int) $this->getData(self::STATUS);
}
public function setStatus(int $status): self
{
return $this->setData(self::STATUS, $status);
}
public function getCreatedAt(): ?string
{
return $this->getData(self::CREATED_AT);
}
public function setCreatedAt(string $createdAt): self
{
return $this->setData(self::CREATED_AT, $createdAt);
}
}
9.6.6 The Resource Model — Model/ResourceModel/StockItem.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Model\ResourceModel;
use Magento\Framework\Model\ResourceModel\Db\AbstractDb;
class StockItem extends AbstractDb
{
protected function _construct()
{
$this->_init('mycompany_inventory_stock_item', 'item_id');
}
}
9.6.7 The Collection — Model/ResourceModel/StockItem/Collection.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Model\ResourceModel\StockItem;
use Magento\Framework\Model\ResourceModel\Db\Collection\AbstractCollection;
use MyCompany\Inventory\Model\StockItem as StockItemModel;
use MyCompany\Inventory\Model\ResourceModel\StockItem as StockItemResourceModel;
class Collection extends AbstractCollection
{
protected $_idFieldName = 'item_id';
protected function _construct()
{
$this->_init(StockItemModel::class, StockItemResourceModel::class);
}
}
9.6.8 The Repository Interface — Api/StockItemRepositoryInterface.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Api;
use MyCompany\Inventory\Api\Data\StockItemInterface;
use Magento\Framework\Api\SearchCriteriaInterface;
interface StockItemRepositoryInterface
{
public function save(StockItemInterface $stockItem): StockItemInterface;
public function getById(int $itemId): StockItemInterface;
public function getBySku(string $sku): StockItemInterface;
public function getList(SearchCriteriaInterface $searchCriteria): \MyCompany\Inventory\Api\Data\StockItemSearchResultsInterface;
public function delete(StockItemInterface $stockItem): bool;
public function deleteById(int $itemId): bool;
}
9.6.9 The Search Results Interface — Api/Data/StockItemSearchResultsInterface.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Api\Data;
use Magento\Framework\Api\SearchResultsInterface;
interface StockItemSearchResultsInterface extends SearchResultsInterface
{
public function getItems(): array;
public function setItems(array $items): self;
}
9.6.10 The Repository Implementation — Model/StockItemRepository.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Model;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
use MyCompany\Inventory\Api\Data\StockItemInterface;
use MyCompany\Inventory\Api\Data\StockItemSearchResultsInterface;
use MyCompany\Inventory\Api\Data\StockItemSearchResultsInterfaceFactory;
use MyCompany\Inventory\Model\ResourceModel\StockItem as StockItemResource;
use MyCompany\Inventory\Model\ResourceModel\StockItem\CollectionFactory;
use Magento\Framework\Api\SearchCriteriaInterface;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Exception\CouldNotSaveException;
use Magento\Framework\Exception\CouldNotDeleteException;
class StockItemRepository implements StockItemRepositoryInterface
{
private array $instancesById = [];
public function __construct(
private readonly StockItemResource $resource,
private readonly StockItemFactory $stockItemFactory,
private readonly CollectionFactory $collectionFactory,
private readonly StockItemSearchResultsInterfaceFactory $searchResultsFactory
) {}
public function save(StockItemInterface $stockItem): StockItemInterface
{
try {
$this->resource->save($stockItem);
unset($this->instancesById[$stockItem->getItemId()]);
} catch (\Exception $e) {
throw new CouldNotSaveException(
__('Could not save the stock item: %1', $e->getMessage()),
$e
);
}
return $stockItem;
}
public function getById(int $itemId): StockItemInterface
{
if (isset($this->instancesById[$itemId])) {
return $this->instancesById[$itemId];
}
$stockItem = $this->stockItemFactory->create();
$this->resource->load($stockItem, $itemId);
if (!$stockItem->getItemId()) {
throw new NoSuchEntityException(
__('Stock item with id "%1" does not exist.', $itemId)
);
}
$this->instancesById[$itemId] = $stockItem;
return $stockItem;
}
public function getBySku(string $sku): StockItemInterface
{
$collection = $this->collectionFactory->create();
$collection->addFieldToFilter('sku', $sku)->setPageSize(1);
$stockItem = $collection->getFirstItem();
if (!$stockItem->getItemId()) {
throw new NoSuchEntityException(
__('Stock item with SKU "%1" does not exist.', $sku)
);
}
return $stockItem;
}
public function getList(SearchCriteriaInterface $searchCriteria): StockItemSearchResultsInterface
{
$collection = $this->collectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$condition = $filter->getConditionType() ?: 'eq';
$collection->addFieldToFilter(
$filter->getField(),
[$condition => $filter->getValue()]
);
}
}
foreach ($searchCriteria->getSortOrders() ?? [] as $sortOrder) {
$collection->addOrder(
$sortOrder->getField(),
$sortOrder->getDirection() === SortOrder::SORT_ASC ? 'ASC' : 'DESC'
);
}
$collection->setCurPage($searchCriteria->getCurrentPage());
$collection->setPageSize($searchCriteria->getPageSize());
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
public function delete(StockItemInterface $stockItem): bool
{
try {
$itemId = $stockItem->getItemId();
$this->resource->delete($stockItem);
unset($this->instancesById[$itemId]);
} catch (\Exception $e) {
throw new CouldNotDeleteException(
__('Could not delete the stock item: %1', $e->getMessage()),
$e
);
}
return true;
}
public function deleteById(int $itemId): bool
{
return $this->delete($this->getById($itemId));
}
}
9.6.11 Dependency Injection Configuration — etc/di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<preference for="MyCompany\Inventory\Api\StockItemRepositoryInterface"
type="MyCompany\Inventory\Model\StockItemRepository"/>
<preference for="MyCompany\Inventory\Api\Data\StockItemInterface"
type="MyCompany\Inventory\Model\StockItem"/>
<preference for="MyCompany\Inventory\Api\Data\StockItemSearchResultsInterface"
type="Magento\Framework\Api\SearchResults"/>
</config>
9.6.12 ACL Permissions — etc/acl.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="MyCompany_Inventory::menu" title="Inventory" sortOrder="100">
<resource id="MyCompany_Inventory::stock_item"
title="Manage Stock Items" sortOrder="10"/>
</resource>
</resource>
</resources>
</acl>
</config>
9.6.13 Admin Routes — etc/adminhtml/routes.xml
<?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="admin">
<route id="mycompany_inventory" frontName="mycompany_inventory">
<module name="MyCompany_Inventory" before="Magento_Backend"/>
</route>
</router>
</config>
9.6.14 Admin Menu — etc/adminhtml/menu.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
<menu>
<add id="MyCompany_Inventory::menu"
title="Inventory"
module="MyCompany_Inventory"
sortOrder="100"
resource="MyCompany_Inventory::menu"
parent="Magento_Backend::content"
action="mycompany_inventory/stockitem/index"/>
<add id="MyCompany_Inventory::stock_item"
title="Stock Items"
module="MyCompany_Inventory"
sortOrder="10"
resource="MyCompany_Inventory::stock_item"
parent="MyCompany_Inventory::menu"
action="mycompany_inventory/stockitem/index"/>
</menu>
</config>
9.6.15 Admin Controllers — One File Per Action
Controller/Adminhtml/StockItem/Index.php — displays the grid page:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Adminhtml\StockItem;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Inventory::stock_item';
public function __construct(
Context $context,
private readonly PageFactory $resultPageFactory
) {
parent::__construct($context);
}
public function execute()
{
$resultPage = $this->resultPageFactory->create();
$resultPage->setActiveMenu('MyCompany_Inventory::stock_item');
$resultPage->getConfig()->getTitle()->prepend(__('Stock Items'));
return $resultPage;
}
}
Controller/Adminhtml/StockItem/Edit.php — handles BOTH “new item” and “edit existing item”:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Adminhtml\StockItem;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
use Magento\Framework\Registry;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
use MyCompany\Inventory\Model\StockItemFactory;
use Magento\Framework\Exception\NoSuchEntityException;
class Edit extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Inventory::stock_item';
public function __construct(
Context $context,
private readonly PageFactory $resultPageFactory,
private readonly Registry $coreRegistry,
private readonly StockItemRepositoryInterface $stockItemRepository,
private readonly StockItemFactory $stockItemFactory
) {
parent::__construct($context);
}
public function execute()
{
$itemId = (int) $this->getRequest()->getParam('item_id');
if ($itemId) {
try {
$stockItem = $this->stockItemRepository->getById($itemId);
} catch (NoSuchEntityException $e) {
$this->messageManager->addErrorMessage(__('This item no longer exists.'));
return $this->resultRedirectFactory->create()
->setPath('mycompany_inventory/stockitem/index');
}
} else {
$stockItem = $this->stockItemFactory->create();
}
$this->coreRegistry->register('mycompany_inventory_stock_item', $stockItem);
$resultPage = $this->resultPageFactory->create();
$resultPage->setActiveMenu('MyCompany_Inventory::stock_item');
$resultPage->getConfig()->getTitle()
->prepend($itemId ? __('Edit Stock Item') : __('New Stock Item'));
return $resultPage;
}
}
Controller/Adminhtml/StockItem/Save.php — processes the form submission:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Adminhtml\StockItem;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
use MyCompany\Inventory\Model\StockItemFactory;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Framework\Exception\LocalizedException;
class Save extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Inventory::stock_item';
public function __construct(
Context $context,
private readonly StockItemRepositoryInterface $stockItemRepository,
private readonly StockItemFactory $stockItemFactory
) {
parent::__construct($context);
}
public function execute()
{
$resultRedirect = $this->resultRedirectFactory->create();
$data = $this->getRequest()->getPostValue();
if (!$data) {
return $resultRedirect->setPath('mycompany_inventory/stockitem/index');
}
$itemId = (int) ($data['item_id'] ?? 0);
try {
if ($itemId) {
$stockItem = $this->stockItemRepository->getById($itemId);
} else {
$stockItem = $this->stockItemFactory->create();
}
$stockItem->setName($data['name'] ?? '');
$stockItem->setSku($data['sku'] ?? '');
$stockItem->setDescription($data['description'] ?? null);
$stockItem->setQuantity((int) ($data['quantity'] ?? 0));
$stockItem->setUnitPrice((float) ($data['unit_price'] ?? 0));
$stockItem->setStatus((int) ($data['status'] ?? 1));
$this->stockItemRepository->save($stockItem);
$this->messageManager->addSuccessMessage(__('The stock item has been saved.'));
if ($this->getRequest()->getParam('back')) {
return $resultRedirect->setPath(
'mycompany_inventory/stockitem/edit',
['item_id' => $stockItem->getItemId()]
);
}
return $resultRedirect->setPath('mycompany_inventory/stockitem/index');
} catch (NoSuchEntityException $e) {
$this->messageManager->addErrorMessage(__('This item no longer exists.'));
} catch (LocalizedException $e) {
$this->messageManager->addErrorMessage($e->getMessage());
} catch (\Exception $e) {
$this->messageManager->addExceptionMessage(
$e,
__('Something went wrong while saving the item.')
);
}
return $resultRedirect->setPath(
'mycompany_inventory/stockitem/edit',
['item_id' => $itemId]
);
}
}
Controller/Adminhtml/StockItem/Delete.php:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Adminhtml\StockItem;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
class Delete extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Inventory::stock_item';
public function __construct(
Context $context,
private readonly StockItemRepositoryInterface $stockItemRepository
) {
parent::__construct($context);
}
public function execute()
{
$resultRedirect = $this->resultRedirectFactory->create();
$itemId = (int) $this->getRequest()->getParam('item_id');
if ($itemId) {
try {
$this->stockItemRepository->deleteById($itemId);
$this->messageManager->addSuccessMessage(__('The item has been deleted.'));
} catch (\Exception $e) {
$this->messageManager->addErrorMessage($e->getMessage());
}
}
return $resultRedirect->setPath('mycompany_inventory/stockitem/index');
}
}
Controller/Adminhtml/StockItem/MassDelete.php:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Adminhtml\StockItem;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
class MassDelete extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Inventory::stock_item';
public function __construct(
Context $context,
private readonly StockItemRepositoryInterface $stockItemRepository
) {
parent::__construct($context);
}
public function execute()
{
$resultRedirect = $this->resultRedirectFactory->create();
$selected = $this->getRequest()->getParam('selected');
if (!is_array($selected)) {
$selected = [];
}
$deletedCount = 0;
foreach ($selected as $itemId) {
try {
$this->stockItemRepository->deleteById((int) $itemId);
$deletedCount++;
} catch (\Exception $e) {
$this->messageManager->addErrorMessage($e->getMessage());
}
}
$this->messageManager->addSuccessMessage(
__('A total of %1 item(s) have been deleted.', $deletedCount)
);
return $resultRedirect->setPath('mycompany_inventory/stockitem/index');
}
}
9.6.16 Admin UI Component — Grid (view/adminhtml/ui_component/mycompany_inventory_stockitem_listing.xml)
<?xml version="1.0" encoding="UTF-8"?>
<listing xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="provider" xsi:type="string">
mycompany_inventory_stockitem_listing.mycompany_inventory_stockitem_listing_data_source
</item>
</item>
</argument>
<settings>
<buttons>
<button name="add">
<url path="mycompany_inventory/stockitem/new"/>
<class>primary</class>
<label translate="true">Add New Item</label>
</button>
</buttons>
<spinner>mycompany_inventory_stockitem_columns</spinner>
<deps>
<dep>mycompany_inventory_stockitem_listing.mycompany_inventory_stockitem_listing_data_source</dep>
</deps>
</settings>
<dataSource name="mycompany_inventory_stockitem_listing_data_source">
<argument name="dataProvider" xsi:type="configurableObject">
<argument name="class" xsi:type="string">
MyCompany\Inventory\Ui\DataProvider\StockItemDataProvider
</argument>
<argument name="name" xsi:type="string">
mycompany_inventory_stockitem_listing_data_source
</argument>
<argument name="primaryFieldName" xsi:type="string">item_id</argument>
<argument name="requestFieldName" xsi:type="string">id</argument>
</argument>
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="component" xsi:type="string">Magento_Ui/js/grid/provider</item>
</item>
</argument>
</dataSource>
<listingToolbar name="listing_top">
<settings><sticky>true</sticky></settings>
<bookmark name="bookmarks"/>
<columnsControls name="columns_controls"/>
<filters name="listing_filters"/>
<massaction name="listing_massaction">
<action name="delete">
<settings>
<type>delete</type>
<label translate="true">Delete</label>
<url path="mycompany_inventory/stockitem/massDelete"/>
<confirm>
<title translate="true">Delete Items</title>
<message translate="true">Are you sure you want to delete the selected items?</message>
</confirm>
</settings>
</action>
</massaction>
<paging name="listing_paging"/>
</listingToolbar>
<columns name="mycompany_inventory_stockitem_columns">
<selectionsColumn name="ids">
<settings><indexField>item_id</indexField></settings>
</selectionsColumn>
<column name="item_id">
<settings>
<filter>textRange</filter>
<label translate="true">ID</label>
<sorting>asc</sorting>
</settings>
</column>
<column name="name">
<settings>
<filter>text</filter>
<label translate="true">Name</label>
</settings>
</column>
<column name="sku">
<settings>
<filter>text</filter>
<label translate="true">SKU</label>
</settings>
</column>
<column name="quantity" class="Magento\Ui\Component\Listing\Columns\Column">
<settings>
<filter>textRange</filter>
<label translate="true">Quantity</label>
</settings>
</column>
<column name="unit_price" class="Magento\Ui\Component\Listing\Columns\Column">
<settings>
<filter>textRange</filter>
<label translate="true">Unit Price</label>
</settings>
</column>
<column name="status" component="Magento_Ui/js/grid/columns/select">
<settings>
<options class="MyCompany\Inventory\Model\Config\Source\Status"/>
<filter>select</filter>
<label translate="true">Status</label>
</settings>
</column>
<column name="created_at" class="Magento\Ui\Component\Listing\Columns\Date">
<settings>
<filter>dateRange</filter>
<label translate="true">Created</label>
</settings>
</column>
<actionsColumn name="actions"
class="MyCompany\Inventory\Ui\Component\Listing\Column\StockItemActions">
<settings><indexField>item_id</indexField></settings>
</actionsColumn>
</columns>
</listing>
Status Source Model (Model/Config/Source/Status.php):
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Model\Config\Source;
use Magento\Framework\Data\OptionSourceInterface;
class Status implements OptionSourceInterface
{
public function toOptionArray(): array
{
return [
['value' => 1, 'label' => __('Active')],
['value' => 0, 'label' => __('Inactive')],
];
}
}
9.6.17 The UI Component Data Provider — Ui/DataProvider/StockItemDataProvider.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Ui\DataProvider;
use Magento\Ui\DataProvider\AbstractDataProvider;
use MyCompany\Inventory\Model\ResourceModel\StockItem\CollectionFactory;
class StockItemDataProvider extends AbstractDataProvider
{
public function __construct(
$name,
$primaryFieldName,
$requestFieldName,
CollectionFactory $collectionFactory,
array $meta = [],
array $data = []
) {
$this->collection = $collectionFactory->create();
parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data);
}
public function getData(): array
{
$data = [];
foreach ($this->collection->getItems() as $item) {
$data[$item->getId()] = $item->getData();
}
return ['items' => array_values($data), 'totalRecords' => $this->collection->getSize()];
}
}
9.6.18 The Actions Column Class — Ui/Component/Listing/Column/StockItemActions.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Ui\Component\Listing\Column;
use Magento\Ui\Component\Listing\Columns\Column;
class StockItemActions extends Column
{
public function prepareDataSource(array $dataSource): array
{
if (isset($dataSource['data']['items'])) {
foreach ($dataSource['data']['items'] as &$item) {
$item[$this->getData('name')] = [
'edit' => [
'href' => $this->getContext()->getUrl(
'mycompany_inventory/stockitem/edit',
['item_id' => $item['item_id']]
),
'label' => __('Edit')
],
'delete' => [
'href' => $this->getContext()->getUrl(
'mycompany_inventory/stockitem/delete',
['item_id' => $item['item_id']]
),
'label' => __('Delete'),
'confirm' => [
'title' => __('Delete %1', $item['name']),
'message' => __('Are you sure you want to delete this item?')
]
]
];
}
}
return $dataSource;
}
}
9.6.19 Admin Form UI Component — view/adminhtml/ui_component/mycompany_inventory_stockitem_form.xml
<?xml version="1.0" encoding="UTF-8"?>
<form xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Ui:etc/ui_configuration.xsd">
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="provider" xsi:type="string">
mycompany_inventory_stockitem_form.stockitem_form_data_source
</item>
</item>
<item name="label" xsi:type="string" translate="true">Stock Item Information</item>
</argument>
<settings>
<buttons>
<button name="back" class="MyCompany\Inventory\Block\Adminhtml\StockItem\Edit\BackButton"/>
<button name="delete" class="MyCompany\Inventory\Block\Adminhtml\StockItem\Edit\DeleteButton"/>
<button name="save" class="MyCompany\Inventory\Block\Adminhtml\StockItem\Edit\SaveButton"/>
<button name="save_and_continue"
class="MyCompany\Inventory\Block\Adminhtml\StockItem\Edit\SaveAndContinueButton"/>
</buttons>
<namespace>mycompany_inventory_stockitem_form</namespace>
<dataScope>data</dataScope>
<deps>
<dep>mycompany_inventory_stockitem_form.stockitem_form_data_source</dep>
</deps>
</settings>
<dataSource name="stockitem_form_data_source">
<argument name="dataProvider" xsi:type="configurableObject">
<argument name="class" xsi:type="string">
MyCompany\Inventory\Ui\DataProvider\StockItemFormDataProvider
</argument>
<argument name="name" xsi:type="string">stockitem_form_data_source</argument>
<argument name="primaryFieldName" xsi:type="string">item_id</argument>
<argument name="requestFieldName" xsi:type="string">item_id</argument>
</argument>
<argument name="data" xsi:type="array">
<item name="js_config" xsi:type="array">
<item name="component" xsi:type="string">Magento_Ui/js/form/provider</item>
</item>
<item name="config" xsi:type="array">
<item name="submit_url" xsi:type="url" path="mycompany_inventory/stockitem/save"/>
</item>
</argument>
</dataSource>
<fieldset name="general">
<settings>
<label translate="true">General Information</label>
</settings>
<field name="item_id" formElement="input">
<settings>
<dataType>text</dataType>
<visible>false</visible>
</settings>
</field>
<field name="name" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">Name</label>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
<field name="sku" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">SKU</label>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
<field name="description" formElement="textarea">
<settings>
<dataType>text</dataType>
<label translate="true">Description</label>
</settings>
</field>
<field name="quantity" formElement="input">
<settings>
<dataType>number</dataType>
<label translate="true">Quantity</label>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
<rule name="validate-number" xsi:type="boolean">true</rule>
<rule name="validate-zero-or-greater" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
<field name="unit_price" formElement="input">
<settings>
<dataType>text</dataType>
<label translate="true">Unit Price</label>
<validation>
<rule name="required-entry" xsi:type="boolean">true</rule>
<rule name="validate-number" xsi:type="boolean">true</rule>
</validation>
</settings>
</field>
<field name="status" formElement="select">
<settings>
<dataType>text</dataType>
<label translate="true">Status</label>
</settings>
<formElements>
<select>
<settings>
<options class="MyCompany\Inventory\Model\Config\Source\Status"/>
</settings>
</select>
</formElements>
</field>
</fieldset>
</form>
Form Data Provider — Ui/DataProvider/StockItemFormDataProvider.php:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Ui\DataProvider;
use Magento\Ui\DataProvider\AbstractDataProvider;
use MyCompany\Inventory\Model\ResourceModel\StockItem\CollectionFactory;
use Magento\Framework\Registry;
class StockItemFormDataProvider extends AbstractDataProvider
{
public function __construct(
$name,
$primaryFieldName,
$requestFieldName,
CollectionFactory $collectionFactory,
private readonly Registry $coreRegistry,
array $meta = [],
array $data = []
) {
$this->collection = $collectionFactory->create();
parent::__construct($name, $primaryFieldName, $requestFieldName, $meta, $data);
}
public function getData(): array
{
$stockItem = $this->coreRegistry->registry('mycompany_inventory_stock_item');
if ($stockItem && $stockItem->getItemId()) {
return [$stockItem->getItemId() => $stockItem->getData()];
}
return [];
}
}
9.6.20 The Save Button Block — Block/Adminhtml/StockItem/Edit/SaveButton.php
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Block\Adminhtml\StockItem\Edit;
use Magento\Ui\Component\Control\Container\ToolbarButtonInterface;
class SaveButton implements ToolbarButtonInterface
{
public function getButtonData(): array
{
return [
'label' => __('Save'),
'class' => 'save primary',
'data_attribute' => [
'mage-init' => ['button' => ['event' => 'save']],
'form-role' => 'save',
],
'sort_order' => 90,
];
}
}
9.6.21 Layout for the Edit Page — view/adminhtml/layout/mycompany_inventory_stockitem_edit.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="content">
<uiComponent name="mycompany_inventory_stockitem_form"/>
</referenceContainer>
</body>
</page>
9.6.22 Frontend — Route, Controller, Layout, Block, Template
etc/frontend/routes.xml:
<?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="inventory" frontName="inventory">
<module name="MyCompany_Inventory"/>
</route>
</router>
</config>
Controller/Index/Index.php:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Controller\Index;
use Magento\Framework\App\Action\HttpGetActionInterface;
use Magento\Framework\View\Result\PageFactory;
class Index implements HttpGetActionInterface
{
public function __construct(
private readonly PageFactory $resultPageFactory
) {}
public function execute()
{
return $this->resultPageFactory->create();
}
}
view/frontend/layout/inventory_index_index.xml:
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
layout="1column"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<title>In-Stock Items</title>
</head>
<body>
<referenceContainer name="content">
<block class="MyCompany\Inventory\Block\ItemList"
name="inventory.item.list"
template="MyCompany_Inventory::item_list.phtml"/>
</referenceContainer>
</body>
</page>
Block/ItemList.php:
<?php
declare(strict_types=1);
namespace MyCompany\Inventory\Block;
use Magento\Framework\View\Element\Template;
use MyCompany\Inventory\Api\StockItemRepositoryInterface;
use Magento\Framework\Api\SearchCriteriaBuilder;
class ItemList extends Template
{
public function __construct(
Template\Context $context,
private readonly StockItemRepositoryInterface $stockItemRepository,
private readonly SearchCriteriaBuilder $searchCriteriaBuilder,
array $data = []
) {
parent::__construct($context, $data);
}
public function getItems(): array
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('status', 1)
->create();
return $this->stockItemRepository->getList($searchCriteria)->getItems();
}
public function formatPrice(float $price): string
{
return '$' . number_format($price, 2);
}
}
view/frontend/templates/item_list.phtml:
<?php
/** @var \MyCompany\Inventory\Block\ItemList $block */
/** @var \Magento\Framework\Escaper $escaper */
$items = $block->getItems();
?>
<div class="inventory-item-list">
<h1><?= $escaper->escapeHtml(__('In-Stock Items')) ?></h1>
<?php if (count($items)): ?>
<table class="inventory-table">
<thead>
<tr>
<th><?= $escaper->escapeHtml(__('Name')) ?></th>
<th><?= $escaper->escapeHtml(__('SKU')) ?></th>
<th><?= $escaper->escapeHtml(__('Quantity')) ?></th>
<th><?= $escaper->escapeHtml(__('Price')) ?></th>
</tr>
</thead>
<tbody>
<?php foreach ($items as $item): ?>
<tr>
<td><?= $escaper->escapeHtml($item->getName()) ?></td>
<td><?= $escaper->escapeHtml($item->getSku()) ?></td>
<td><?= $escaper->escapeHtml((string) $item->getQuantity()) ?></td>
<td><?= $escaper->escapeHtml($block->formatPrice($item->getUnitPrice())) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php else: ?>
<p><?= $escaper->escapeHtml(__('No items currently in stock.')) ?></p>
<?php endif; ?>
</div>
9.6.23 REST API — etc/webapi.xml
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/inventory/items/:itemId" method="GET">
<service class="MyCompany\Inventory\Api\StockItemRepositoryInterface" method="getById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/inventory/items" method="GET">
<service class="MyCompany\Inventory\Api\StockItemRepositoryInterface" method="getList"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/inventory/items" method="POST">
<service class="MyCompany\Inventory\Api\StockItemRepositoryInterface" method="save"/>
<resources>
<resource ref="MyCompany_Inventory::stock_item"/>
</resources>
</route>
<route url="/V1/inventory/items/:itemId" method="DELETE">
<service class="MyCompany\Inventory\Api\StockItemRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="MyCompany_Inventory::stock_item"/>
</resources>
</route>
</routes>
9.6.24 Deployment — Putting It All Together
php bin/magento module:enable MyCompany_Inventory
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento cache:flush
Expected results after deployment:
- Visit
http://magento.local/admin/mycompany_inventory/stockitem/index— see an empty admin grid with an “Add New Item” button - Click “Add New Item” — fill the form (Name, SKU, Quantity, Unit Price) — click Save
- Try saving with an empty Name — see both the client-side red validation AND the server-side LocalizedException message
- The new item now appears in the admin grid
- Visit
http://magento.local/inventoryon the frontend — see the same item listed - Query the REST API at
/rest/V1/inventory/items— see the same data, in JSON
PART THREE: ADVANCED SYSTEMS & PERFORMANCE
Chapter 10: Events, Observers & Plugins
Magento provides three primary ways to extend or react to core behaviour without modifying core code: events/observers and plugins (interceptors).
10.1 Event System
The event system follows the Observer pattern. Magento dispatches named events at various points in its execution, and any module can observe these events by declaring an observer in events.xml.
Why use events:
- Loose coupling — the dispatching code does not know what observers exist
- Multiple independent extensions can react to the same event
- You cannot modify the original method’s arguments or return value (only react)
Core events reference:
| Event Name | When Dispatched | Available Data |
|---|---|---|
| catalog_product_save_after | After product saved | product |
| checkout_cart_add_product_complete | After product added to cart | product |
| sales_order_place_after | After order placed | order |
| customer_register_success | After customer registration | customer |
| controller_action_predispatch | Before any controller action | controller_action |
| cms_page_render | Before rendering CMS page | page |
10.2 Custom Events
You can dispatch your own events to allow other modules to react to your code:
<?php
namespace MyCompany\Blog\Model;
use Magento\Framework\Event\ManagerInterface;
class ItemProcessor
{
public function __construct(
private readonly ManagerInterface $eventManager
) {}
public function processItem($itemData)
{
$this->eventManager->dispatch(
'mycompany_blog_item_process_before',
['item' => $itemData]
);
// ... processing logic ...
$result = ['success' => true];
$this->eventManager->dispatch(
'mycompany_blog_item_process_after',
['item' => $itemData, 'result' => $result]
);
return $result;
}
}
Convention: event names should be lowercase with underscores. Use the pattern vendor_module_action_stage.
10.3 Observers
An observer is a class that reacts to an event. It must implement ObserverInterface with an execute(Observer $observer) method.
Step 1 — Create the observer class:
Path: app/code/MyCompany/Blog/Observer/LogProductSave.php
<?php
declare(strict_types=1);
namespace MyCompany\Blog\Observer;
use Magento\Framework\Event\ObserverInterface;
use Magento\Framework\Event\Observer;
use Psr\Log\LoggerInterface;
class LogProductSave implements ObserverInterface
{
public function __construct(
private readonly LoggerInterface $logger
) {}
public function execute(Observer $observer): void
{
$product = $observer->getEvent()->getProduct();
$this->logger->info('Product saved: SKU ' . $product->getSku());
}
}
Step 2 — Register in events.xml:
Path: app/code/MyCompany/Blog/etc/events.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Event/etc/events.xsd">
<event name="catalog_product_save_after">
<observer name="mycompany_blog_log_product_save"
instance="MyCompany\Blog\Observer\LogProductSave"
sortOrder="10" />
</event>
</config>
After creating the observer: php bin/magento cache:flush.
10.4 Plugins (Interceptors)
Plugins allow you to modify the behaviour of any public method of a class. They are defined in di.xml.
Limitations — plugins cannot be applied to:
finalmethods orfinalclasses- Static methods
- Constructors (
__construct) - Non-public methods
Three types of plugin methods:
| Type | Method Naming | When it Runs | Typical Use |
|---|---|---|---|
| Before | beforeMethodName() | Before the original method | Validate or modify arguments |
| After | afterMethodName() | After the original method | Modify the return value |
| Around | aroundMethodName() | Wraps the original method | Add logic before AND after, or skip the original |
10.4.1 Before Plugin
<?php
namespace MyCompany\Blog\Plugin\Catalog\Model;
use Magento\Catalog\Model\Product;
class ProductPlugin
{
public function beforeSetName(Product $subject, string $name): array
{
$cleaned = ucfirst(trim($name));
return [$cleaned];
}
public function beforeSetSku(Product $subject, string $sku): array
{
if (strlen($sku) < 3) {
throw new \InvalidArgumentException('SKU must be at least 3 characters');
}
return [strtoupper($sku)];
}
}
10.4.2 After Plugin
public function afterGetName(Product $subject, string $result): string
{
if ($subject->getSpecialPrice()) {
return $result . ' (SALE)';
}
return $result;
}
10.4.3 Around Plugin
public function aroundSave(
Product $subject,
callable $proceed
): Product {
$oldSku = $subject->getOrigData('sku');
$result = $proceed();
$newSku = $subject->getSku();
return $result;
}
Use around plugins sparingly — before + after is often sufficient.
10.4.4 Register the Plugin in di.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd">
<type name="Magento\Catalog\Model\Product">
<plugin name="mycompany_blog_product_plugin"
type="MyCompany\Blog\Plugin\Catalog\Model\ProductPlugin"
sortOrder="10"
disabled="false"/>
</type>
</config>
10.5 Preferences — Class Overrides
A preference completely replaces one class with another.
<preference for="Magento\Catalog\Model\Product"
type="MyCompany\Blog\Model\Catalog\Product"/>
When to use preferences vs plugins:
| Task | Best Solution |
|---|---|
| Modify arguments before a method call | Before plugin |
| Modify return value after a method | After plugin |
| Add behaviour both before and after | Around plugin |
| Add a completely new method to a class | Preference |
| Change constructor signature | Preference or virtual type |
| Fix a bug not fixable by plugin | Preference (last resort) |
| React to an action (log, send email) | Observer |
10.6 Extension Strategies — Decision Guide
Start: What do you need?
│
├─ Add behaviour before/after a method? → Plugin (before/after)
├─ Completely wrap a method? → Plugin (around)
├─ Add a new method to a class? → Preference
├─ Change constructor arguments? → Virtual type or preference
├─ React to an event (order placed, product saved)? → Observer
└─ None of the above? → You may not need to extend
Chapter 11: Admin Development
11.1 Admin Routes
<?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="admin">
<route id="mycompany_blog" frontName="mycompany_blog">
<module name="MyCompany_Blog" before="Magento_Backend"/>
</route>
</router>
</config>
11.2 Admin Controllers
<?php
namespace MyCompany\Blog\Controller\Adminhtml\Post;
use Magento\Backend\App\Action;
use Magento\Backend\App\Action\Context;
use Magento\Framework\View\Result\PageFactory;
class Index extends Action
{
const ADMIN_RESOURCE = 'MyCompany_Blog::posts';
public function __construct(
Context $context,
private readonly PageFactory $resultPageFactory
) {
parent::__construct($context);
}
public function execute()
{
$resultPage = $this->resultPageFactory->create();
$resultPage->setActiveMenu('MyCompany_Blog::blog');
$resultPage->getConfig()->getTitle()->prepend(__('Manage Blog Posts'));
return $resultPage;
}
}
11.3 Admin Menus
Path: app/code/MyCompany/Blog/etc/adminhtml/menu.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Backend:etc/menu.xsd">
<menu>
<add id="MyCompany_Blog::menu"
title="Blog"
module="MyCompany_Blog"
sortOrder="100"
resource="MyCompany_Blog::menu"
parent="Magento_Backend::content"
action="mycompany_blog/post/index"/>
<add id="MyCompany_Blog::posts"
title="Manage Posts"
module="MyCompany_Blog"
sortOrder="10"
resource="MyCompany_Blog::posts"
parent="MyCompany_Blog::menu"
action="mycompany_blog/post/index"/>
<add id="MyCompany_Blog::config"
title="Configuration"
module="MyCompany_Blog"
sortOrder="20"
resource="MyCompany_Blog::config"
parent="MyCompany_Blog::menu"
action="adminhtml/system_config/edit/section/mycompany_blog"/>
</menu>
</config>
11.4 ACL Resources
Path: app/code/MyCompany/Blog/etc/acl.xml
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Acl/etc/acl.xsd">
<acl>
<resources>
<resource id="Magento_Backend::admin">
<resource id="MyCompany_Blog::menu" title="Blog" sortOrder="100">
<resource id="MyCompany_Blog::posts" title="Manage Posts" sortOrder="10"/>
<resource id="MyCompany_Blog::config" title="Configuration" sortOrder="20"/>
</resource>
</resource>
</resources>
</acl>
</config>
11.5 Admin Dashboards
Path: view/adminhtml/layout/adminhtml_dashboard_index.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<body>
<referenceContainer name="dashboard.main">
<block class="MyCompany\Blog\Block\Adminhtml\Dashboard\Stats"
name="blog.dashboard.stats"
template="MyCompany_Blog::dashboard/stats.phtml"
after="-"/>
</referenceContainer>
</body>
</page>
11.6 UI Components — Admin Grids and Forms
Admin Grid example: view/adminhtml/ui_component/mycompany_blog_post_listing.xml (see Chapter 8.3.1 for full XML).
Mass Action Controller:
<?php
namespace MyCompany\Blog\Controller\Adminhtml\Post;
use Magento\Backend\App\Action;
class MassDelete extends Action
{
public function execute()
{
$ids = $this->getRequest()->getParam('selected');
if (!is_array($ids)) {
$ids = [];
}
$deleted = 0;
foreach ($ids as $id) {
try {
$item = $this->itemRepository->getById($id);
$this->itemRepository->delete($item);
$deleted++;
} catch (\Exception $e) {
$this->messageManager->addErrorMessage($e->getMessage());
}
}
$this->messageManager->addSuccessMessage(__('Deleted %1 items', $deleted));
return $this->resultRedirectFactory->create()->setPath('mycompany_blog/post/index');
}
protected function _isAllowed()
{
return $this->_authorization->isAllowed('MyCompany_Blog::posts');
}
}
Chapter 12: Frontend Development
12.1 Theme Development
A Magento theme is a collection of files (layout XML, templates, static assets) that define the visual appearance.
Creating a custom theme:
mkdir -p app/design/frontend/MyCompany/HelloTheme/etc
mkdir -p app/design/frontend/MyCompany/HelloTheme/Magento_Theme/layout
mkdir -p app/design/frontend/MyCompany/HelloTheme/web/css
etc/theme.xml:
<theme xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:Config/etc/theme.xsd">
<title>Hello Theme</title>
<parent>Magento/blank</parent>
<media>
<preview_image>media/preview.jpg</preview_image>
</media>
</theme>
registration.php:
<?php
use Magento\Framework\Component\ComponentRegistrar;
ComponentRegistrar::register(
ComponentRegistrar::THEME,
'frontend/MyCompany/HelloTheme',
__DIR__
);
Overriding templates: copy the original file from the module into your theme at the same relative path.
12.2 Layout XML — Deep Dive
Layout XML files define the structure of a page. The file name matches the page handle.
Key elements:
| Element | Purpose |
|---|---|
<page> | Root element; attributes: layout (1column, 2columns-left), etc. |
<head> | Add CSS, JS, and meta tags |
<referenceContainer> | Modify an existing container |
<container> | Define a new container |
<block> | Define a block |
<referenceBlock> | Modify an existing block |
<move> | Move a block/container to a different parent |
<remove> | Remove a block/container |
<update> | Include another layout handle |
Adding custom CSS to every page:
Path: Magento_Theme/layout/default.xml
<?xml version="1.0"?>
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="css/custom.css" />
</head>
</page>
12.3 Blocks and Templates
Blocks are PHP classes that prepare data for templates. Templates are .phtml files that generate HTML.
Creating a block:
<?php
namespace MyCompany\Blog\Block;
use Magento\Framework\View\Element\Template;
use MyCompany\Blog\Api\BlogPostRepositoryInterface;
class PostList extends Template
{
public function __construct(
Template\Context $context,
private readonly BlogPostRepositoryInterface $postRepository,
array $data = []
) {
parent::__construct($context, $data);
}
public function getPosts(): array
{
$searchCriteria = $this->searchCriteriaBuilder
->addFilter('is_active', 1)
->create();
return $this->postRepository->getList($searchCriteria)->getItems();
}
}
Template:
<?php
/** @var \MyCompany\Blog\Block\PostList $block */
$posts = $block->getPosts();
?>
<div class="blog-post-list">
<h1><?= __('Blog Posts') ?></h1>
<?php if (count($posts)): ?>
<?php foreach ($posts as $post): ?>
<article>
<h2><?= $escaper->escapeHtml($post->getTitle()) ?></h2>
<div class="content"><?= $post->getContent() ?></div>
</article>
<?php endforeach; ?>
<?php else: ?>
<p><?= __('No posts found.') ?></p>
<?php endif; ?>
</div>
12.4 View Models
View models are a modern alternative to blocks that encourage separation of presentation logic:
<?php
namespace MyCompany\Blog\ViewModel;
use Magento\Framework\View\Element\Block\ArgumentInterface;
class PostViewModel implements ArgumentInterface
{
public function getMessage(): string
{
return 'Hello from view model!';
}
public function getFormattedDate(\DateTimeInterface $date): string
{
return $date->format('M j, Y');
}
}
Inject in layout XML:
<block class="Magento\Framework\View\Element\Template"
name="blog.post"
template="MyCompany_Blog::post.phtml">
<arguments>
<argument name="view_model" xsi:type="object">
MyCompany\Blog\ViewModel\PostViewModel
</argument>
</arguments>
</block>
12.5 LESS and CSS
Magento uses LESS as its CSS preprocessor. LESS files are compiled to CSS during static content deployment.
Adding CSS in a module:
Place view/frontend/web/css/blog.css and include via layout:
<page xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/page_configuration.xsd">
<head>
<css src="MyCompany_Blog::css/blog.css"/>
</head>
</page>
12.6 JavaScript Architecture
Magento’s frontend JavaScript stack consists of RequireJS for module loading, KnockoutJS for reactive UI, jQuery for DOM manipulation, and Magento UI Components for complex widgets.
RequireJS configuration:
Path: view/frontend/requirejs-config.js
var config = {
map: {
'*': {
'hello': 'MyCompany_HelloWorld/js/hello',
'customWidget': 'MyCompany_HelloWorld/js/widget/custom'
}
},
paths: {
'someLibrary': 'https://cdn.example.com/library'
}
};
Creating a custom RequireJS module:
Path: view/frontend/web/js/hello.js
define(['jquery'], function($) {
'use strict';
return {
init: function() {
console.log('Hello module loaded');
}
};
});
Creating a jQuery widget:
define(['jquery', 'jquery/ui'], function($) {
'use strict';
$.widget('mage.customWidget', {
options: {
delay: 500,
message: 'Hello'
},
_create: function() {
this.element.on('click', $.proxy(this._handleClick, this));
},
_handleClick: function() {
alert(this.options.message);
}
});
return $.mage.customWidget;
});
Mixins — extend JS modules without overriding:
// File: view/frontend/web/js/price-mixin.js
define(['jquery'], function($) {
'use strict';
return function(targetWidget) {
$.widget('mage.priceBox', targetWidget, {
_initPriceBox: function() {
console.log('Custom price box init');
this._super();
}
});
return targetWidget;
};
});
Register in requirejs-config.js:
var config = {
config: {
mixins: {
'Magento_Catalog/js/price-box': {
'MyCompany_HelloWorld/js/price-mixin': true
}
}
}
};
12.7 Hyvä Theme Development
Hyvä is a modern, performance-optimised Magento theme that completely replaces the default frontend stack with Tailwind CSS and Alpine.js.
Alpine.js for interactivity:
<div x-data="{ open: false }">
<button @click="open = !open">Toggle</button>
<div x-show="open">Hidden content</div>
</div>
Tailwind CSS:
<div class="bg-white shadow rounded-lg p-6 mb-4">
<h2 class="text-2xl font-bold text-gray-800">Product Name</h2>
<p class="text-green-600 font-semibold mt-2">$49.99</p>
</div>
12.8 PWA Studio — Headless Magento Storefront
PWA Studio is Magento’s official toolchain for building Progressive Web Apps.
Setup:
npx @magento/pwa-buildpack create-pwa-app my-pwa-storefront
cd my-pwa-storefront
Configure .env:
MAGENTO_BACKEND_URL=https://magento.local
GRAPHQL_ENDPOINT=/graphql
PORT=3000
yarn install
yarn watch # Development at http://localhost:3000
yarn build # Production build
GraphQL query example:
query getProduct($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
name
price {
regularPrice {
amount { value currency }
}
}
description { html }
}
}
}
Chapter 13: Commerce Systems
13.1 Catalog Architecture
The catalog is a core component of every e-commerce platform. Magento’s catalog architecture is built around the EAV model for products and categories.
Core components:
- Product —
catalog_product_entity - Category —
catalog_category_entity - Attribute — defines product properties
- Attribute Set — a collection of attributes grouped together
Key tables:
| Table | Purpose |
|---|---|
| catalog_product_entity | Base product table |
| catalog_product_entity_varchar | Text values for product attributes |
| catalog_product_entity_int | Integer values |
| catalog_product_entity_decimal | Decimal values |
| catalog_product_entity_datetime | Date values |
| eav_attribute | Attribute definitions |
Loading a product programmatically:
$product = $this->productRepository->get('sku123');
$product->setPrice(99.95);
$this->productRepository->save($product);
13.2 Product Types
- Simple Product — single SKU, single price, basic inventory:
$product = $this->productFactory->create();
$product->setSku('test-simple')
->setName('Simple Test Product')
->setTypeId(\Magento\Catalog\Model\Product\Type::TYPE_SIMPLE)
->setPrice(29.99)
->setWeight(1.5)
->setStatus(\Magento\Catalog\Model\Product\Attribute\Source\Status::STATUS_ENABLED)
->setVisibility(\Magento\Catalog\Model\Product\Visibility::VISIBILITY_BOTH)
->setStockData([
'use_config_manage_stock' => 0,
'manage_stock' => 1,
'is_in_stock' => 1,
'qty' => 100
]);
$this->productRepository->save($product);
- Configurable Product — multiple variants (e.g., T-shirt in different colors and sizes).
- Grouped Product — group of independent products presented together.
- Bundle Product — customers choose from a set of options.
- Virtual Product — does not require shipping.
- Downloadable Product — digital product providing downloadable files.
- Gift Card Product — prepaid gift card.
13.3 Category Management
use Magento\Catalog\Api\CategoryRepositoryInterface;
class CategoryService
{
public function __construct(
private readonly CategoryRepositoryInterface $categoryRepository
) {}
public function getCategoryTree($categoryId)
{
$category = $this->categoryRepository->get($categoryId);
$children = $category->getChildrenCategories();
$tree = ['name' => $category->getName(), 'children' => []];
foreach ($children as $child) {
$tree['children'][] = [
'name' => $child->getName(),
'id' => $child->getId()
];
}
return $tree;
}
}
13.4 Inventory Management (MSI)
Magento’s Multi-Source Inventory (MSI) allows you to manage stock across multiple physical locations.
Core MSI concepts:
- Source — a physical location where inventory is stored
- Stock — an aggregation of sources representing salable inventory
- Salable Quantity — quantity available for sale
- Reservations — temporary holds on inventory for pending orders
Getting salable quantity:
$salableQty = $this->getSalableQuantity->execute($sku, $stockId);
13.5 Customer Management
use Magento\Customer\Api\CustomerRepositoryInterface;
class CustomerService
{
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository
) {}
public function getCustomer($email)
{
return $this->customerRepository->get($email);
}
public function updateCustomer($email, $data)
{
$customer = $this->customerRepository->get($email);
if (isset($data['firstname'])) {
$customer->setFirstname($data['firstname']);
}
if (isset($data['group_id'])) {
$customer->setGroupId($data['group_id']);
}
$this->customerRepository->save($customer);
return $customer;
}
}
13.6 Checkout Architecture
The checkout is one of the most complex parts of Magento. It involves multiple steps implemented as UI components (Knockout.js) communicating with the backend via AJAX.
Checkout steps:
- Shipping Address
- Shipping Method
- Payment Method
- Order Review
The Quote (Cart) is the temporary container for items before checkout.
Getting current cart:
use Magento\Checkout\Model\Cart;
class CartService
{
public function __construct(private readonly Cart $cart) {}
public function getCartItems()
{
$quote = $this->cart->getQuote();
$items = $quote->getAllVisibleItems();
$data = [];
foreach ($items as $item) {
$data[] = [
'sku' => $item->getSku(),
'name' => $item->getName(),
'qty' => $item->getQty(),
'price' => $item->getPrice(),
'row_total' => $item->getRowTotal()
];
}
return $data;
}
}
13.7 Sales Architecture
Core entities:
- Order —
Magento\Sales\Model\Order - Order Item — each product in the order
- Invoice — a record of payment capture
- Shipment — a record of physical shipment
- Credit Memo — a record of refund
Order lifecycle states: Pending → Processing → Complete (or Cancelled, Closed)
use Magento\Sales\Api\OrderRepositoryInterface;
class OrderService
{
public function __construct(
private readonly OrderRepositoryInterface $orderRepository
) {}
public function getOrder($incrementId)
{
return $this->orderRepository->get($incrementId);
}
public function updateOrderStatus($incrementId, $status, $state)
{
$order = $this->orderRepository->get($incrementId);
$order->setStatus($status);
$order->setState($state);
$this->orderRepository->save($order);
}
}
Sales events:
sales_order_place_after— after order is placedsales_order_save_after— after order is savedsales_order_invoice_save_after— after invoice is savedsales_order_shipment_save_after— after shipment is saved
13.8 Custom Payment Gateway
<?php
namespace MyCompany\CustomPayment\Model;
use Magento\Payment\Model\Method\AbstractMethod;
use Magento\Payment\Model\InfoInterface;
class PaymentMethod extends AbstractMethod
{
protected $_code = 'custom_bank_transfer';
protected $_isOffline = true;
protected $_canAuthorize = true;
protected $_canCapture = true;
protected $_canRefund = true;
public function authorize(InfoInterface $payment, $amount)
{
$payment->setTransactionId($this->generateTransactionId())
->setIsTransactionClosed(0)
->setAdditionalInformation(
'bank_account',
$this->getConfigData('bank_account')
);
return $this;
}
public function capture(InfoInterface $payment, $amount)
{
if ($payment->getTransactionId() === null) {
$payment->setTransactionId($this->generateTransactionId());
}
$payment->setIsTransactionClosed(0);
return $this;
}
public function refund(InfoInterface $payment, $amount)
{
$payment->setTransactionId(
$this->generateTransactionId() . '-refund'
);
$payment->setIsTransactionClosed(1);
return $this;
}
private function generateTransactionId()
{
return 'CBT-' . time() . '-' . random_int(1000, 9999);
}
}
13.9 Custom Shipping Carrier
<?php
namespace MyCompany\CustomShipping\Model\Carrier;
use Magento\Quote\Model\Quote\Address\RateRequest;
use Magento\Shipping\Model\Carrier\AbstractCarrier;
use Magento\Shipping\Model\Carrier\CarrierInterface;
class CustomFlatRate extends AbstractCarrier implements CarrierInterface
{
protected $_code = 'customflatrate';
public function collectRates(RateRequest $request)
{
if (!$this->getConfigFlag('active')) {
return false;
}
$result = $this->rateResultFactory->create();
$shippingPrice = $this->getConfigData('price');
$freeThreshold = $this->getConfigData('free_shipping_threshold');
if ($freeThreshold && $request->getBaseSubtotalInclTax() >= $freeThreshold) {
$shippingPrice = 0;
}
$method = $this->rateMethodFactory->create();
$method->setCarrier($this->_code);
$method->setCarrierTitle($this->getConfigData('title'));
$method->setMethod($this->_code);
$method->setMethodTitle($this->getConfigData('name'));
$method->setPrice($shippingPrice);
$result->append($method);
return $result;
}
public function getAllowedMethods()
{
return [$this->_code => $this->getConfigData('name')];
}
}
13.10 Promotions and Marketing
Catalog Price Rules — apply discounts directly to product prices:
$rule = $this->catalogRuleFactory->create();
$rule->setName('20% Off Summer Collection')
->setFromDate(date('Y-m-d'))
->setToDate(date('Y-m-d', strtotime('+30 days')))
->setIsActive(true)
->setCustomerGroupIds([1, 2])
->setDiscountAmount(20)
->setSimpleAction('by_percent')
->setWebsiteIds([1]);
$rule->save();
Cart Price Rules — apply discounts to the cart total (coupons):
$rule = $this->salesRuleFactory->create();
$rule->setName('10% Off $100+')
->setIsActive(true)
->setCouponType(\Magento\SalesRule\Model\Rule::COUPON_TYPE_SPECIFIC)
->setCouponCode('DISCOUNT10')
->setUsesPerCoupon(1)
->setDiscountAmount(10)
->setSimpleAction('by_percent')
->setWebsiteIds([1]);
$rule->save();
Chapter 14: API Development & Integrations
14.1 API Overview – REST, GraphQL, Webhooks, and API Consumers
14.1.1 REST API
REST is an architectural style that uses HTTP methods (GET, POST, PUT, DELETE) to manipulate resources. Magento’s REST API exposes modules’ service contracts as endpoints defined in webapi.xml.
Endpoint structure: /rest/{store_code}/{version}/{module}/{route}
14.1.2 GraphQL API
GraphQL is a query language that lets clients request only the specific data they require, often through a single request. In Magento, GraphQL is the preferred API approach for building headless storefronts.
14.1.3 Webhooks
Webhooks are user-defined HTTP callbacks triggered by specific events (e.g., order placed, product updated).
14.1.4 API Consumers (Integrations)
API consumers (called “Integrations” in Magento) are OAuth-authenticated clients that can access REST APIs on behalf of an admin user.
14.2 Creating Custom REST API Endpoints (webapi.xml)
Step 1: Ensure Service Contract Exists
We will use the BlogPostRepositoryInterface with methods getById, save, deleteById, getList.
Step 2: Define Routes in webapi.xml
Path: app/code/MyCompany/Blog/etc/webapi.xml
<?xml version="1.0"?>
<routes xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Webapi:etc/webapi.xsd">
<route url="/V1/blog/posts/:postId" method="GET">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="getById"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts" method="GET">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="getList"/>
<resources>
<resource ref="anonymous"/>
</resources>
</route>
<route url="/V1/blog/posts" method="POST">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="save"/>
<resources>
<resource ref="Magento_Backend::admin"/>
</resources>
</route>
<route url="/V1/blog/posts/:postId" method="DELETE">
<service class="MyCompany\Blog\Api\BlogPostRepositoryInterface" method="deleteById"/>
<resources>
<resource ref="Magento_Backend::admin"/>
</resources>
</route>
</routes>
Step 3: Modify Repository to Support getList with Search Criteria
public function getList(\Magento\Framework\Api\SearchCriteriaInterface $searchCriteria)
{
$collection = $this->collectionFactory->create();
foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
foreach ($filterGroup->getFilters() as $filter) {
$condition = $filter->getConditionType() ?: 'eq';
$collection->addFieldToFilter($filter->getField(), [$condition => $filter->getValue()]);
}
}
$collection->setCurPage($searchCriteria->getCurrentPage());
$collection->setPageSize($searchCriteria->getPageSize());
foreach ($searchCriteria->getSortOrders() as $sortOrder) {
$collection->addOrder($sortOrder->getField(), $sortOrder->getDirection());
}
$searchResults = $this->searchResultsFactory->create();
$searchResults->setSearchCriteria($searchCriteria);
$searchResults->setItems($collection->getItems());
$searchResults->setTotalCount($collection->getSize());
return $searchResults;
}
Step 4: Test with cURL
# Get a public post
curl -X GET "https://yourstore.com/rest/V1/blog/posts/1" \
-H "Content-Type: application/json"
# Get list of posts with filters
curl -X GET "https://yourstore.com/rest/V1/blog/posts?searchCriteria[filterGroups][0][filters][0][field]=is_active&searchCriteria[filterGroups][0][filters][0][value]=1" \
-H "Content-Type: application/json"
# Create a post (admin token required)
TOKEN=$(curl -X POST "https://yourstore.com/rest/V1/integration/admin/token" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin123!"}' | tr -d '"')
curl -X POST "https://yourstore.com/rest/V1/blog/posts" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"post": {
"title": "New API Post",
"content": "<p>Created via REST API</p>",
"is_active": true
}
}'
Step 5: Creating an API Consumer (Integration)
Admin panel → System → Integrations → Add New Integration → set name, email, resources → save → Activate → copy the token.
14.3 Developing Custom GraphQL Schemas & Resolvers
Step 1: Define Schema in schema.graphqls
Path: app/code/MyCompany/Blog/etc/schema.graphqls
type BlogPost {
post_id: Int!
title: String!
content: String!
is_active: Boolean!
created_at: String!
}
type BlogPostList {
items: [BlogPost!]!
total_count: Int!
}
type Query {
blogPost(id: Int!): BlogPost @resolver(class: "MyCompany\\Blog\\Model\\Resolver\\BlogPostResolver")
blogPosts(pageSize: Int = 10, currentPage: Int = 1): BlogPostList @resolver(class: "MyCompany\\Blog\\Model\\Resolver\\BlogPostsResolver")
}
Step 2: Create Resolver for Single Post
Path: app/code/MyCompany/Blog/Model/Resolver/BlogPostResolver.php
<?php
namespace MyCompany\Blog\Model\Resolver;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\GraphQl\Exception\GraphQlNoSuchEntityException;
use MyCompany\Blog\Api\BlogPostRepositoryInterface;
class BlogPostResolver implements ResolverInterface
{
private BlogPostRepositoryInterface $blogPostRepository;
public function __construct(BlogPostRepositoryInterface $blogPostRepository)
{
$this->blogPostRepository = $blogPostRepository;
}
public function resolve(
Field $field,
$context,
ResolveInfo $info,
array $value = null,
array $args = null
) {
$id = $args['id'] ?? 0;
try {
$post = $this->blogPostRepository->getById($id);
return $post->getData();
} catch (\Magento\Framework\Exception\NoSuchEntityException $e) {
throw new GraphQlNoSuchEntityException(__('Blog post with id %1 does not exist', $id));
}
}
}
Step 3: Create Resolver for List of Posts
Path: app/code/MyCompany/Blog/Model/Resolver/BlogPostsResolver.php
<?php
namespace MyCompany\Blog\Model\Resolver;
use Magento\Framework\GraphQl\Query\ResolverInterface;
use Magento\Framework\GraphQl\Config\Element\Field;
use Magento\Framework\GraphQl\Schema\Type\ResolveInfo;
use Magento\Framework\Api\SearchCriteriaBuilder;
class BlogPostsResolver implements ResolverInterface
{
private \MyCompany\Blog\Api\BlogPostRepositoryInterface $blogPostRepository;
private SearchCriteriaBuilder $searchCriteriaBuilder;
public function __construct(
\MyCompany\Blog\Api\BlogPostRepositoryInterface $blogPostRepository,
SearchCriteriaBuilder $searchCriteriaBuilder
) {
$this->blogPostRepository = $blogPostRepository;
$this->searchCriteriaBuilder = $searchCriteriaBuilder;
}
public function resolve(
Field $field,
$context,
ResolveInfo $info,
array $value = null,
array $args = null
) {
$pageSize = $args['pageSize'] ?? 10;
$currentPage = $args['currentPage'] ?? 1;
$searchCriteria = $this->searchCriteriaBuilder
->setPageSize($pageSize)
->setCurrentPage($currentPage)
->addFilter('is_active', 1)
->create();
$result = $this->blogPostRepository->getList($searchCriteria);
return [
'items' => $result->getItems(),
'total_count' => $result->getTotalCount()
];
}
}
Step 4: Test GraphQL Query
query {
blogPost(id: 1) {
post_id
title
content
}
}
14.4 Integrating Third-Party APIs
Step 1: Create a Service Class with HTTP Client
<?php
namespace MyCompany\Blog\Model;
use Magento\Framework\HTTP\Client\Curl;
use Magento\Framework\Serialize\Serializer\Json;
class WeatherService
{
private Curl $curl;
private Json $json;
private const API_KEY = 'your_api_key_here';
public function __construct(Curl $curl, Json $json)
{
$this->curl = $curl;
$this->json = $json;
}
public function getCurrentWeather(string $city): array
{
$url = sprintf(
'https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric',
urlencode($city),
self::API_KEY
);
$this->curl->get($url);
$response = $this->curl->getBody();
return $this->json->unserialize($response);
}
}
Best practice: store API keys in admin configuration via system.xml, then retrieve using ScopeConfigInterface.
14.5 API Best Practices & Security
Authentication:
- Use
resource ref="anonymous"only for data safe to expose publicly - Always require authentication for customer personal data or order details
Input validation:
public function getById(int $id): ItemInterface
{
if ($id <= 0) {
throw new \Magento\Framework\Exception\InputException(
__('Invalid item ID: %1', $id)
);
}
// ...
}
Rate limiting using Redis:
public function checkRateLimit(string $ip): void
{
$cacheKey = 'rate_limit_' . $ip;
$requests = (int) $this->cache->load($cacheKey);
if ($requests > 100) {
throw new \Magento\Framework\Exception\LocalizedException(
__('Rate limit exceeded'),
null,
429
);
}
$this->cache->save($requests + 1, $cacheKey, [], 60);
}
Chapter 15: Performance Engineering
15.1 Cache Architecture
Caching is the most critical performance optimisation in Magento. A well-configured cache can reduce page load times from seconds to milliseconds.
Cache types reference:
| Cache Type | Description |
|---|---|
| config | System configuration, module settings |
| layout | Layout XML compiled into structures |
| block_html | Rendered HTML of individual blocks |
| collections | Database query results |
| full_page | Complete HTML of pages (most important) |
| translate | Translated strings |
Managing cache types:
php bin/magento cache:status
php bin/magento cache:enable
php bin/magento cache:flush
php bin/magento cache:clean config
php bin/magento cache:disable block_html
Full Page Cache (FPC): Caches complete HTML pages so they can be delivered faster when users request them again. Two options:
- Built-in FPC (Redis-based) — suitable for small stores
- Varnish — enterprise-grade, recommended for production
Configure FPC:
php bin/magento config:set system/full_page_cache/caching_application 2 # 2 = Varnish
php bin/magento config:set system/full_page_cache/ttl 86400
Block cache configuration in layout XML:
<block class="MyCompany\HelloWorld\Block\Hello"
name="hello.block"
template="MyCompany_HelloWorld::hello.phtml"
cacheable="true">
<arguments>
<argument name="cache_lifetime" xsi:type="number">86400</argument>
<argument name="cache_key" xsi:type="string">hello_block</argument>
</arguments>
</block>
15.2 Redis Configuration
// app/etc/env.php
'cache' => [
'frontend' => [
'default' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => '127.0.0.1',
'port' => '6379',
'database' => '0',
'compression' => true,
],
],
'page_cache' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => [
'server' => '127.0.0.1',
'port' => '6379',
'database' => '1',
'compress_data' => false,
],
],
],
],
'session' => [
'save' => 'redis',
'redis' => [
'host' => '127.0.0.1',
'port' => '6379',
'database' => '2',
'timeout' => 2.5,
'max_concurrency' => 6,
'min_lifetime' => 60,
'max_lifetime' => 2592000,
],
],
Redis tuning in /etc/redis/redis.conf:
maxmemory 512mb
maxmemory-policy allkeys-lru
Monitor Redis:
redis-cli ping # Returns PONG
redis-cli INFO keyspace # Shows databases with key counts
redis-cli --stat # Real-time stats
15.3 Varnish Configuration
# Install (Ubuntu)
sudo apt install varnish -y
sudo systemctl start varnish
sudo systemctl enable varnish
# Configure Magento to use Varnish
php bin/magento config:set system/full_page_cache/caching_application 2
# Export Varnish VCL from admin:
# Stores → Configuration → Advanced → System → Full Page Cache
# → Varnish Configuration → Export VCL for Varnish 6
# Apply the VCL
sudo cp /path/to/downloaded/varnish.vcl /etc/varnish/default.vcl
sudo systemctl restart varnish
# Verify Varnish is working
curl -I http://yourdomain.com | grep X-Magento-Cache-Debug
# HIT = cached, MISS = first load
15.4 Elasticsearch and OpenSearch
Installation (Ubuntu):
wget -qO - https://artifacts.elastic.co/GPG-KEY-elasticsearch | sudo apt-key add -
sudo apt-add-repository "deb https://artifacts.elastic.co/packages/7.x/apt stable main"
sudo apt update && sudo apt install elasticsearch -y
sudo systemctl start elasticsearch && sudo systemctl enable elasticsearch
# Verify
curl -X GET "localhost:9200/"
Configure in Magento:
php bin/magento setup:install ... \
--search-engine=opensearch \
--opensearch-host=localhost \
--opensearch-port=9200
Reindex search:
php bin/magento indexer:reindex catalogsearch_fulltext
15.5 Indexers
Indexers update data stores (database tables) for fast querying.
php bin/magento indexer:reindex # Reindex all
php bin/magento indexer:reindex catalog_product_price # Specific indexer
php bin/magento indexer:status # Show status
# Set to "Update by Schedule" (recommended for production)
php bin/magento indexer:set-mode schedule catalog_product_price
Important indexers:
| Indexer | Affects |
|---|---|
| catalog_product_price | Price filtering, product list |
| catalog_product_attribute | Layered navigation, product page |
| catalogsearch_fulltext | Product search |
| cataloginventory_stock | Stock availability |
| catalog_category_product | Category pages |
15.6 Query Optimisation
// Good: Filtered, limited, specific fields
$collection = $this->productCollectionFactory->create();
$collection->addAttributeToSelect(['name', 'price', 'sku'])
->addAttributeToFilter('status', 1)
->addAttributeToFilter('price', ['gt' => 10])
->setPageSize(20)
->setOrder('name', 'ASC');
// Bad: Unfiltered, all fields
$collection = $this->productCollectionFactory->create();
$collection->addAttributeToSelect('*');
Preventing N+1 queries:
// Bad: N+1 queries
foreach ($orders as $order) {
$customer = $this->customerRepository->getById($order->getCustomerId()); // N queries
}
// Good: Load all at once
$customerIds = array_column($orders, 'customer_id');
$customers = $this->customerRepository->getList(
$this->searchCriteriaBuilder->addFilter('entity_id', $customerIds, 'in')->create()
)->getItems();
15.7 High Performance Magento Checklist
# Set production mode
php bin/magento deploy:mode:set production
# Compile DI
php bin/magento setup:di:compile
# Deploy static content
php bin/magento setup:static-content:deploy -f en_US
# Flush cache
php bin/magento cache:flush
# Set indexers to Update by Schedule
php bin/magento indexer:set-mode schedule
Performance checklist:
- [ ] Enable Full Page Cache (Varnish or built-in)
- [ ] Use Redis for cache and sessions
- [ ] Set indexers to “Update by Schedule”
- [ ] Enable Minification (merge and minify CSS/JS)
- [ ] Enable Gzip/Brotli compression on web server
- [ ] Use CDN for static assets
- [ ] Optimise images (compress, use WebP)
- [ ] Remove unused modules
- [ ] Use production-ready server (Nginx, PHP-FPM, OPcache)
OPcache configuration in php.ini:
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=60000
opcache.revalidate_freq=0
Chapter 16: Security Engineering
16.1 Authentication
Customer authentication:
if (!$this->customerSession->isLoggedIn()) {
$redirect = $this->redirectFactory->create();
$redirect->setPath('customer/account/login');
return $redirect;
}
$customer = $this->customerSession->getCustomer();
Password hashing:
use Magento\Framework\Encryption\EncryptorInterface;
class PasswordService
{
public function __construct(
private readonly EncryptorInterface $encryptor
) {}
public function hashPassword($password)
{
return $this->encryptor->getHash($password, true);
}
public function verifyPassword($password, $hash)
{
return $this->encryptor->isValidHash($password, $hash);
}
}
Encrypting sensitive data:
$encrypted = $this->encryptor->encrypt($sensitiveValue);
$decrypted = $this->encryptor->decrypt($encryptedValue);
16.2 Secure Coding Practices
XSS Prevention — always escape output:
| Method | Use Case |
|---|---|
| escapeHtml() | HTML output (most common) |
| escapeUrl() | URLs in href, src |
| escapeJs() | JavaScript strings |
| escapeCss() | CSS values |
| escapeHtmlAttr() | HTML attribute values |
<!-- HTML output -->
<div><?= $escaper->escapeHtml($userInput) ?></div>
<!-- URL attributes -->
<a href="<?= $escaper->escapeUrl($url) ?>">Link</a>
<!-- HTML attributes -->
<div data-attr="<?= $escaper->escapeHtmlAttr($dynamicAttr) ?>">
<!-- JavaScript strings -->
<script>
var config = <?= $escaper->escapeJs(json_encode($configArray)) ?>;
</script>
CSRF Prevention:
<?= $block->getBlockHtml('formkey') ?>
Validate in controllers:
public function execute()
{
if (!$this->_formKeyValidator->validate($this->getRequest())) {
$this->messageManager->addErrorMessage('Invalid form key.');
return $this->_redirect('*/*/');
}
}
SQL Injection Prevention:
// WRONG — vulnerable
$sql = "SELECT * FROM table WHERE id = " . $_GET['id'];
// CORRECT — parameterized
$select = $connection->select()
->from($this->getMainTable())
->where('item_id = :id');
$result = $connection->fetchRow($select, ['id' => $itemId]);
// CORRECT — using collection
$collection->addFieldToFilter('item_id', $itemId);
16.3 ACL and Authorization
protected function _isAllowed()
{
return $this->_authorization->isAllowed('MyCompany_HelloWorld::manage_items');
}
16.4 Two-Factor Authentication (2FA)
Enable and configure in admin:
php bin/magento config:set twofactorauth/enable 1
php bin/magento config:set twofactorauth/google/active 1
16.5 reCAPTCHA
- Get reCAPTCHA keys from https://www.google.com/recaptcha
- Configure: Stores → Configuration → Security → Google reCAPTCHA
- Enter Site Key and Secret Key
- Select where reCAPTCHA should appear
16.6 Security Patches
Checking and applying patches:
# Check current version
php bin/magento --version
# Update Magento core with security fixes
composer update magento/product-community-edition --with-dependencies
# Run setup upgrade after update
php bin/magento setup:upgrade
php bin/magento cache:flush
# Quality Patches Tool
composer require magento/quality-patches
./vendor/bin/magento-patches status
./vendor/bin/magento-patches apply MAGETWO-XXXX
./vendor/bin/magento-patches revert MAGETWO-XXXX
Where to find patches:
- Adobe Security Center: https://helpx.adobe.com/security/products/magento.html
- Magento Marketplace: https://marketplace.magento.com
Patch best practices:
- Always test on staging before production
- Keep a log of patches applied and when
- Use Git to track patch changes
- Backup database and files before patching
16.7 PCI Compliance Basics
| Requirement | How Magento Helps | Developer Responsibility |
|---|---|---|
| Secure networks | Supports TLS 1.2+ | Configure web server for HTTPS only |
| Protect cardholder data | Encryption (EncryptorInterface) | Never log card numbers; use tokenisation |
| Strong access control | ACL, 2FA, reCAPTCHA | Enforce 2FA for all admin users |
| Monitor and test | Logging (var/log) | Monitor exception.log, set up alerts |
Practical PCI steps:
- Never store full PAN — use tokenisation (Braintree, Stripe)
- Restrict admin access by IP via .htaccess or Nginx
- Enable 2FA for all admin users
- Keep Magento and all extensions up to date
- Use HTTPS everywhere
PART FOUR: DEVOPS & ENTERPRISE
Chapter 17: DevOps and Cloud
17.1 Docker
Complete Magento Docker stack (docker-compose.yml):
version: '3.8'
services:
db:
image: mysql:8.0
container_name: magento_db
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento
MYSQL_USER: magento_user
MYSQL_PASSWORD: StrongPassword123!
ports: ["3306:3306"]
volumes: [db-data:/var/lib/mysql]
networks: [magento-network]
redis:
image: redis:7
ports: ["6379:6379"]
networks: [magento-network]
opensearch:
image: opensearchproject/opensearch:2.11.0
environment:
- discovery.type=single-node
- DISABLE_SECURITY_PLUGIN=true
- OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m
ports: ["9200:9200"]
networks: [magento-network]
rabbitmq:
image: rabbitmq:3-management
environment:
RABBITMQ_DEFAULT_USER: guest
RABBITMQ_DEFAULT_PASS: guest
ports: ["5672:5672", "15672:15672"]
networks: [magento-network]
php:
build:
context: ./docker/php
dockerfile: Dockerfile
volumes: [./magento:/var/www/magento]
depends_on: [db, redis, opensearch, rabbitmq]
networks: [magento-network]
nginx:
image: nginx:latest
ports: ["80:80"]
volumes:
- ./magento:/var/www/magento
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on: [php]
networks: [magento-network]
networks:
magento-network:
driver: bridge
volumes:
db-data:
Common Docker commands:
docker compose up -d # Start all containers
docker compose ps # View running containers
docker compose logs -f # View logs
docker compose exec php bash # Enter PHP container
docker compose down # Stop all containers
docker compose down -v # Stop and remove volumes
docker compose exec php php bin/magento cache:flush # Run Magento commands
17.2 RabbitMQ — Message Queues
Configuration in env.php:
'queue' => [
'amqp' => [
'host' => 'rabbitmq',
'port' => 5672,
'user' => 'guest',
'password' => 'guest',
'virtualhost' => '/',
],
],
Queue topology (etc/queue_topology.xml):
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/topology.xsd">
<exchange name="blog.exchange" type="topic" connection="amqp">
<binding id="blog.post.published"
topic="blog.post.published"
destinationType="queue"
destination="blog.post.published.queue"/>
</exchange>
</config>
Queue consumer (etc/queue_consumer.xml):
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:framework-message-queue:etc/consumer.xsd">
<consumer name="blog.post.published.consumer"
queue="blog.post.published.queue"
connection="amqp"
handler="MyCompany\Blog\Model\Queue\BlogPostConsumer::process"/>
</config>
Consumer class:
<?php
namespace MyCompany\Blog\Model\Queue;
use Psr\Log\LoggerInterface;
class BlogPostConsumer
{
public function __construct(
private readonly LoggerInterface $logger
) {}
public function process(string $message): void
{
$data = json_decode($message, true);
$this->logger->info('Processing blog post: ' . $data['post_id']);
}
}
Starting consumer:
php bin/magento queue:consumers:start blog.post.published.consumer
php bin/magento queue:consumers:list # List available consumers
17.3 Cron Jobs
Cron class:
<?php
namespace MyCompany\Blog\Cron;
use Psr\Log\LoggerInterface;
class CleanupOldPosts
{
public function __construct(
private readonly \MyCompany\Blog\Api\BlogPostRepositoryInterface $blogPostRepository,
private readonly LoggerInterface $logger
) {}
public function execute(): void
{
$thirtyDaysAgo = date('Y-m-d H:i:s', strtotime('-30 days'));
$this->logger->info('Cleanup cron executed');
}
}
Cron configuration (etc/crontab.xml):
<?xml version="1.0"?>
<config xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:module:Magento_Cron:etc/crontab.xsd">
<group id="default">
<job name="mycompany_blog_cleanup"
instance="MyCompany\Blog\Cron\CleanupOldPosts"
method="execute">
<schedule>0 2 * * *</schedule>
</job>
</group>
</config>
Cron schedule syntax:
* * * * *— Runs every minute.0 * * * *— Runs at the start of every hour.0 2 * * *— Runs every day at 2:00 AM.*/5 * * * *— Runs every five minutes.0 0 * * 0— Runs every Sunday at midnight.
Managing cron:
php bin/magento cron:install # Install system crontab entry
php bin/magento cron:run # Run cron manually
php bin/magento cron:list # List all cron jobs
php bin/magento cron:status # Check cron status
17.4 CI/CD
GitHub Actions:
name: Magento CI/CD
on:
push:
branches: [main, develop]
pull_request:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: magento
ports: ["3306:3306"]
redis:
image: redis:7
ports: ["6379:6379"]
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.2'
extensions: bcmath,curl,gd,intl,mbstring,pdo_mysql,soap,xsl,zip
- name: Install dependencies
run: |
composer config http-basic.repo.magento.com \
${{ secrets.MAGENTO_PUBLIC_KEY }} \
${{ secrets.MAGENTO_PRIVATE_KEY }}
composer install --prefer-dist
- name: Run PHPCS
run: vendor/bin/phpcs app/code/ --standard=Magento2
- name: Run PHPStan
run: vendor/bin/phpstan analyse app/code/ --level=5
- name: Run Unit Tests
run: vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v4
- name: Deploy to production
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SSH_KEY }}
script: |
cd /var/www/magento
php bin/magento maintenance:enable
composer install --no-dev --prefer-dist
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento cache:flush
php bin/magento maintenance:disable
17.5 Kubernetes
Deployment manifest:
apiVersion: apps/v1
kind: Deployment
metadata:
name: magento-app
namespace: magento
spec:
replicas: 3
selector:
matchLabels:
app: magento
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: magento
spec:
containers:
- name: php-fpm
image: myregistry/magento-php:latest
env:
- name: DB_HOST
value: "mysql-service"
- name: REDIS_HOST
value: "redis-service"
resources:
requests:
memory: "1Gi"
cpu: "500m"
limits:
memory: "2Gi"
cpu: "2"
livenessProbe:
httpGet:
path: /health
port: 9000
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /ready
port: 9000
initialDelaySeconds: 10
periodSeconds: 5
- name: nginx
image: myregistry/magento-nginx:latest
ports:
- containerPort: 80
Common Kubernetes commands:
kubectl apply -f k8s/
kubectl rollout status deployment/magento-app -n magento
kubectl scale deployment/magento-app -n magento --replicas=5
kubectl rollout undo deployment/magento-app -n magento
kubectl logs -f deployment/magento-app -n magento
kubectl exec -it deployment/magento-app -n magento -- /bin/bash
17.6 Adobe Commerce Cloud
Adobe Commerce Cloud is a managed cloud platform with built-in CI/CD, Fastly CDN, and auto-scaling.
.magento.app.yaml (build configuration):
name: magento
type: php:8.2
build:
flavor: none
dependencies:
php:
composer/composer: '^2'
runtime:
extensions:
- bcmath
- curl
- gd
- intl
- mbstring
- pdo_mysql
- soap
- xsl
- zip
- redis
- opcache
relationships:
database: "db:mysql"
redis: "cache:redis"
opensearch: "search:opensearch"
hooks:
build: |
set -e
composer install --no-interaction --prefer-dist --no-dev
deploy: |
set -e
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento cache:flush
post_deploy: |
php bin/magento indexer:reindex
php bin/magento cron:run
web:
locations:
"/":
root: "pub"
passthru: "/index.php"
"/static":
root: "pub/static"
allow: true
expires: 1y
crons:
"magento-cron":
spec: "* * * * *"
cmd: "php bin/magento cron:run"
Cloud CLI commands:
magento-cloud login
magento-cloud project:get <project-id>
git push origin main # Deploys automatically
magento-cloud log # View logs
magento-cloud ssh # SSH into environment
magento-cloud db:dump # Dump database
magento-cloud snapshot:create # Create snapshot
Chapter 18: Enterprise Commerce
18.1 Multi-Store Architecture
Magento’s three-level hierarchy:
| Level | Description | Example |
|---|---|---|
| Website | Top-level; own customers, orders, checkout | US Website, EU Website |
| Store | Shares category structure with parent website | English Store, French Store |
| Store View | Defines language, currency, theme | English (US), English (UK) |
Creating a new website and store view:
Admin → Stores → All Stores → Create Website → Create Store → Create Store View
Configure base URL per store view:
php bin/magento config:set web/unsecure/base_url https://uk.magento.local/ \
--scope=stores --scope-code=uk_store
php bin/magento config:set web/secure/base_url https://uk.magento.local/ \
--scope=stores --scope-code=uk_store
Nginx multi-store configuration:
server {
listen 80;
server_name french.yourstore.com;
set $MAGE_ROOT /var/www/magento2;
set $MAGE_RUN_TYPE store;
set $MAGE_RUN_CODE fr;
include /var/www/magento2/nginx.conf.sample;
}
18.2 Multi-Language Stores
Creating translation dictionary:
php bin/magento i18n:collect-phrases -o app/design/frontend/MyCompany/mytheme/i18n/fr_FR.csv
Edit the CSV:
"Add to Cart","Ajouter au panier"
"Search","Rechercher"
Deploy translations:
php bin/magento setup:static-content:deploy fr_FR
Configure language per store view:
php bin/magento config:set general/locale/code fr_FR \
--scope=stores --scope-code=fr_store
18.3 Multi-Currency Stores
Enable currencies:
php bin/magento config:set currency/options/allow USD,GBP,EUR,JPY
php bin/magento config:set currency/options/base USD
php bin/magento config:set currency/options/default USD
# Per store
php bin/magento config:set currency/options/default GBP \
--scope=stores --scope-code=uk_store
Update exchange rates:
php bin/magento currency:update
18.4 B2B Commerce
B2B features are included with Adobe Commerce. Enable:
php bin/magento module:enable Magento_B2b
php bin/magento setup:upgrade
php bin/magento cache:flush
Creating a company programmatically:
<?php
namespace MyCompany\B2b\Model;
use Magento\Company\Api\CompanyRepositoryInterface;
use Magento\Company\Api\Data\CompanyInterfaceFactory;
class CompanyCreator
{
public function __construct(
private readonly CompanyInterfaceFactory $companyFactory,
private readonly CompanyRepositoryInterface $companyRepository
) {}
public function createCompany(string $name, int $adminCustomerId): int
{
$company = $this->companyFactory->create();
$company->setCompanyName($name);
$company->setStatus(\Magento\Company\Api\Data\CompanyInterface::STATUS_APPROVED);
$company->setSuperUserId($adminCustomerId);
$savedCompany = $this->companyRepository->save($company);
return (int) $savedCompany->getId();
}
}
18.5 Headless Commerce
In a headless architecture, Magento provides APIs that power frontends built independently:
GraphQL query in React:
import { useQuery } from '@apollo/client';
import gql from 'graphql-tag';
const GET_PRODUCT = gql`
query GetProduct($sku: String!) {
products(filter: { sku: { eq: $sku } }) {
items {
name
sku
price_range {
minimum_price {
regular_price { value currency }
}
}
description { html }
}
}
}
`;
function ProductPage({ sku }: { sku: string }) {
const { data, loading, error } = useQuery(GET_PRODUCT, { variables: { sku } });
if (loading) return <div>Loading...</div>;
if (error) return <div>Error loading product.</div>;
const product = data?.products?.items?.[0];
return (
<div>
<h1>{product.name}</h1>
<p>${product.price_range.minimum_price.regular_price.value}</p>
</div>
);
}
Chapter 19: AI Commerce
19.1 AI Product Recommendations
Adobe Commerce includes a native Product Recommendations module powered by Adobe Sensei.
Setting up Adobe Commerce Product Recommendations:
php bin/magento module:enable Magento_ProductRecommendations
php bin/magento setup:upgrade
php bin/magento config:set product_recommendations/environment_id <your-environment-id>
php bin/magento config:set product_recommendations/api_key <your-api-key>
php bin/magento cache:flush
Important AI architecture principle: every AI feature should be additive, not deeply embedded. The store works perfectly without AI, and AI progressively enhances when available. Always implement graceful degradation.
19.2 AI Search (Adobe Commerce Live Search)
Live Search replaces the default OpenSearch engine with an AI-powered, SaaS-based search:
php bin/magento config:set live_search/general/enabled 1
php bin/magento config:set live_search/general/environment_id <your-environment-id>
php bin/magento config:set live_search/general/api_key <your-api-key>
Key features:
- Natural Language Understanding
- Real-Time Re-ranking
- Dynamic Faceting
- Typo Tolerance
- Zero-Results Prevention
19.3 AI Chatbot Integration
<?php
namespace MyCompany\AiChat\Controller\Index;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\Controller\Result\JsonFactory;
use Magento\Framework\HTTP\Client\Curl;
class Chat implements HttpPostActionInterface
{
private const OPENAI_API_URL = 'https://api.openai.com/v1/chat/completions';
public function __construct(
private readonly \Magento\Framework\App\RequestInterface $request,
private readonly JsonFactory $jsonFactory,
private readonly Curl $curl,
private readonly \Psr\Log\LoggerInterface $logger,
private readonly string $openAiApiKey
) {}
public function execute()
{
$result = $this->jsonFactory->create();
$userMessage = trim($this->request->getParam('message', ''));
if (empty($userMessage)) {
return $result->setData(['reply' => 'Please type a message.']);
}
try {
$payload = json_encode([
'model' => 'gpt-4',
'max_tokens' => 300,
'messages' => [
['role' => 'system', 'content' => 'You are a helpful shopping assistant.'],
['role' => 'user', 'content' => $userMessage],
],
], JSON_THROW_ON_ERROR);
$this->curl->addHeader('Content-Type', 'application/json');
$this->curl->addHeader('Authorization', 'Bearer ' . $this->openAiApiKey);
$this->curl->post(self::OPENAI_API_URL, $payload);
$response = json_decode($this->curl->getBody(), true, 512, JSON_THROW_ON_ERROR);
$reply = $response['choices'][0]['message']['content'] ?? "I'm having trouble responding.";
return $result->setData(['reply' => $reply]);
} catch (\Exception $e) {
$this->logger->error('AI chat API error', ['error' => $e->getMessage()]);
return $result->setData([
'reply' => "I'm temporarily unavailable. Please use our help center."
]);
}
}
}
19.4 ChatGPT and Claude Integration
Using ChatGPT for Magento development (effective prompts):
Generate a complete module:
“Generate a complete Magento 2 module named MyCompany_HelloWorld. Include registration.php, module.xml, a routes.xml for frontend route ‘helloworld’, a controller Index/Index.php that returns a JSON response with ‘message’ => ‘Hello World’, and a di.xml file with a simple plugin example. Use Magento coding standards.”
Debug an error:
“Why is Magento throwing ‘Area code not set’ when I try to load a product in a console command? My command code is: [paste your command code].”
GitHub Copilot for Magento: install the GitHub Copilot extension in VS Code or PhpStorm, then type comments describing your intent.
Chapter 20: Solution Architect Track
20.1 Enterprise System Design
Architecture decision factors for enterprise commerce:
Monolith vs Microservices:
| Aspect | Monolith | Microservices |
|---|---|---|
| Deployment | Single | Independent |
| Scalability | Scale everything | Scale individually |
| Complexity | Lower (code) | Higher (infrastructure) |
| Time to Market | Slower | Faster per service |
| Best For | Moderate complexity | Large organisations, multiple teams |
Headless vs Traditional:
| Aspect | Traditional | Headless |
|---|---|---|
| Frontend | Magento themes | React, Vue, Next.js |
| Performance | Good | Excellent |
| Omnichannel | Limited | Native |
| Development Speed | Slower | Faster |
Architecture Decision Records (ADRs):
# ADR-001: Choose Headless Architecture
## Context
Building a new storefront for enterprise brand with multiple channels.
## Decision
Use headless architecture with Magento as backend and React-based frontend.
## Rationale
- Single backend serves multiple channels
- Frontend teams can work independently
- Better performance with PWA
## Consequences
- Increased frontend complexity
- Requires GraphQL expertise
- Higher initial development cost
## Status
Accepted
## Date
2025-01-01
20.2 Scalability Patterns
Horizontal scaling requirements — make the application stateless:
// All session state in Redis (env.php)
'session' => [
'save' => 'redis',
'redis' => ['host' => 'redis-cluster.internal', 'port' => '6379', 'database' => '2'],
],
// All cache in Redis
'cache' => [
'frontend' => [
'default' => [
'backend' => 'Magento\\Framework\\Cache\\Backend\\Redis',
'backend_options' => ['server' => 'redis-cluster.internal', 'port' => '6379'],
],
],
],
Database read replicas:
'db' => [
'connection' => [
'default' => [
'host' => 'master-db.internal',
'dbname' => 'magento',
],
'read' => [
'host' => 'replica-db.internal',
'dbname' => 'magento',
'active' => '1',
],
],
],
20.3 High Availability
Redundancy for every critical component:
| Component | Redundancy Strategy |
|---|---|
| Web Servers | Multiple instances behind load balancer |
| Database | Master-slave or multi-master replication |
| Cache | Redis cluster |
| Search | Elasticsearch/OpenSearch cluster |
| Load Balancer | Active-passive or active-active pair |
Graceful degradation example:
class SearchService
{
public function search(string $query): array
{
try {
return $this->openSearchClient->search($query);
} catch (\Exception $e) {
$this->logger->error('OpenSearch failed, falling back to DB: ' . $e->getMessage());
return $this->productRepository->searchByName($query); // Fallback
}
}
}
20.4 Load Balancing
Nginx as load balancer:
upstream magento_backend {
least_conn; # Least connections algorithm
server web1.example.com:80 weight=3;
server web2.example.com:80 weight=3;
server web3.example.com:80 weight=2;
keepalive 32;
}
server {
listen 80;
server_name store.example.com;
location / {
proxy_pass http://magento_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
Load balancing algorithms:
| Algorithm | Description | Use Case |
|---|---|---|
| Round-Robin | Sequential distribution | Simple, uniform traffic |
| Least Connections | Sends to server with fewest active connections | Variable request durations |
| IP Hash | Consistent hashing based on client IP | Sticky sessions |
| Weighted Round-Robin | Servers have weights based on capacity | Unequal server capacity |
20.5 Disaster Recovery
DR strategies:
| Strategy | RTO | RPO | Cost | Use Case |
|---|---|---|---|---|
| Backup and Restore | Hours-Days | Hours | Low | Simple DR |
| Pilot Light | Minutes-Hours | Near real-time | Medium | Cost-efficient HA |
| Warm Standby | Minutes | Near real-time | High | Critical systems |
| Active-Active | Seconds | Zero | Very High | Global performance |
DR Runbook:
- Incident Detection
- Alert fires, confirm site is down from multiple locations
- Notify the DR team
- Assessment
- Component failure or site-wide?
- Expected recovery time? Data lost?
- Initiate Failover
- Update DNS to DR site IP
- Wait for DNS propagation (TTL: 300 seconds)
- Verify site accessible at DR site
- Stabilise
- Monitor performance, process queued orders
- Notify customers if needed
- Recovery
- Identify root cause, fix issue in primary site
- Plan failback during low-traffic period
- Post-Incident Review
- What went well? Wrong? How to improve?
20.6 Technical Leadership
Key responsibilities:
| Responsibility | Description |
|---|---|
| Architecture Design | Design enterprise-scale systems, create ADRs |
| Technology Strategy | Define technical direction and standards |
| Technical Decision-Making | Make trade-off decisions (build vs buy) |
| Mentoring | Guide junior developers, conduct code reviews |
| Stakeholder Management | Translate technical concepts for business leaders |
| Risk Management | Identify and mitigate technical risks early |
Architecture review checklist:
- [ ] Does the solution meet business requirements?
- [ ] Can it handle expected traffic growth?
- [ ] What are the failure modes and fallbacks?
- [ ] How is data protected at rest and in transit?
- [ ] Is the code well-structured and testable?
- [ ] How is the system monitored?
PART FIVE: TESTING, CAREER & PROFESSIONAL DEVELOPMENT
Chapter 21: Testing and Code Quality
21.1 PHPUnit — Unit Tests
# Run all unit tests for a module
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist app/code/MyCompany/HelloWorld/Test/Unit
# Run specific test file
vendor/bin/phpunit -c dev/tests/unit/phpunit.xml.dist \
app/code/MyCompany/HelloWorld/Test/Unit/Model/ItemTest.php
Writing a unit test:
Path: app/code/MyCompany/HelloWorld/Test/Unit/Model/ItemTest.php
<?php
namespace MyCompany\HelloWorld\Test\Unit\Model;
use PHPUnit\Framework\TestCase;
use MyCompany\HelloWorld\Model\Item;
class ItemTest extends TestCase
{
private Item $item;
protected function setUp(): void
{
$this->item = new Item();
}
public function testSetAndGetName()
{
$this->item->setData('name', 'Test Item');
$this->assertEquals('Test Item', $this->item->getData('name'));
}
}
Testing with mock objects:
<?php
namespace MyCompany\HelloWorld\Test\Unit\Model;
use MyCompany\HelloWorld\Model\ItemService;
use MyCompany\HelloWorld\Api\ItemRepositoryInterface;
use PHPUnit\Framework\TestCase;
use Magento\Framework\Exception\NoSuchEntityException;
class ItemServiceTest extends TestCase
{
private $itemRepositoryMock;
private ItemService $itemService;
protected function setUp(): void
{
parent::setUp();
$this->itemRepositoryMock = $this->createMock(ItemRepositoryInterface::class);
$this->itemService = new ItemService($this->itemRepositoryMock);
}
public function testGetItemById()
{
$itemId = 1;
$expectedItem = $this->createMock(\MyCompany\HelloWorld\Api\Data\ItemInterface::class);
$expectedItem->method('getName')->willReturn('Test Item');
$this->itemRepositoryMock
->expects($this->once())
->method('getById')
->with($itemId)
->willReturn($expectedItem);
$result = $this->itemService->getItemById($itemId);
$this->assertSame($expectedItem, $result);
}
}
21.2 Integration Tests
<?php
namespace MyCompany\HelloWorld\Test\Integration\Model;
use Magento\TestFramework\Helper\Bootstrap;
use MyCompany\HelloWorld\Api\ItemRepositoryInterface;
class ItemRepositoryTest extends \PHPUnit\Framework\TestCase
{
private ItemRepositoryInterface $repository;
protected function setUp(): void
{
$objectManager = Bootstrap::getObjectManager();
$this->repository = $objectManager->get(ItemRepositoryInterface::class);
}
public function testSaveAndLoad()
{
$objectManager = Bootstrap::getObjectManager();
$item = $objectManager->create(\MyCompany\HelloWorld\Api\Data\ItemInterface::class);
$item->setData('name', 'Integration Test Item');
$item->setData('status', 'active');
$saved = $this->repository->save($item);
$this->assertNotNull($saved->getId());
$loaded = $this->repository->getById($saved->getId());
$this->assertEquals('Integration Test Item', $loaded->getData('name'));
}
}
vendor/bin/phpunit -c dev/tests/integration/phpunit.xml.dist \
app/code/MyCompany/HelloWorld/Test/Integration
21.3 MFTF — Magento Functional Testing Framework
Complete MFTF Setup:
# Step 1: Install MFTF
composer require --dev magento/magento2-functional-testing-framework
# Step 2: Generate MFTF configuration
vendor/bin/mftf build:project
# Step 3: Download ChromeDriver
# Find your Chrome version at chrome://settings/help
# Download matching ChromeDriver from https://chromedriver.chromium.org/
chmod +x /usr/local/bin/chromedriver
# Step 4: Start Selenium Server
java -jar selenium-server-4.15.0.jar standalone
# Step 5: Configure dev/tests/acceptance/.env
.env configuration:
MAGENTO_BASE_URL=http://magento2.local
MAGENTO_BACKEND_NAME=admin
MAGENTO_ADMIN_USERNAME=admin
MAGENTO_ADMIN_PASSWORD=Admin123!
SELENIUM_HOST=localhost
SELENIUM_PORT=4444
BROWSER=chrome
# Step 6: Generate and run tests
vendor/bin/mftf generate:tests
vendor/bin/mftf run:test AdminLoginTest
Creating a MFTF test:
Path: Test/Mftf/Test/HelloWorldPageTest.xml
<?xml version="1.0" encoding="UTF-8"?>
<tests xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="urn:magento:mftf:Test/etc/testSchema.xsd">
<test name="HelloWorldPageTest">
<annotations>
<title value="Hello World page displays correctly"/>
<description value="Verify that the Hello World page loads and shows greeting"/>
<group value="hello_world"/>
<group value="frontend"/>
</annotations>
<amOnPage url="/helloworld/index/index" stepKey="goToHelloWorldPage"/>
<waitForElement selector=".hello-world-message" time="10" stepKey="waitForMessage"/>
<seeElement selector=".hello-world-message h1" stepKey="seeGreeting"/>
<see userInput="Hello, Magento World" stepKey="verifyGreetingText"/>
</test>
</tests>
21.4 API Testing
<?php
namespace MyCompany\HelloWorld\Test\Integration\Api;
use Magento\TestFramework\TestCase\WebapiAbstract;
class ItemApiTest extends WebapiAbstract
{
private const RESOURCE_PATH = '/V1/helloworld/items';
public function testGetItemById()
{
$serviceInfo = [
'rest' => [
'resourcePath' => self::RESOURCE_PATH . '/1',
'httpMethod' => \Magento\Framework\Webapi\Rest\Request::HTTP_METHOD_GET,
],
];
$response = $this->_webApiCall($serviceInfo);
$this->assertArrayHasKey('item_id', $response);
$this->assertArrayHasKey('name', $response);
}
}
21.5 Code Quality Tools
PHP_CodeSniffer (PHPCS):
composer require --dev magento/php-coding-standard
vendor/bin/phpcs app/code/MyCompany/HelloWorld --standard=Magento2
vendor/bin/phpcbf app/code/MyCompany/HelloWorld --standard=Magento2 # Auto-fix
Magento Coding Standard key rules:
| Rule | Example |
|---|---|
| Strict types | declare(strict_types=1); at top of every PHP file |
| Return type declarations | public function getName(): string |
| Property visibility | Always private or protected, never var |
| Constructor injection | No ObjectManager::getInstance() |
| Escaping | $escaper->escapeHtml() in templates |
| Line length | Maximum 120 characters |
PHPStan (Static Analysis):
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse app/code/MyCompany/HelloWorld --level=5
Pull request review checklist:
- [ ] All code successfully passes the required tests, including PHPUnit, Integration, and MFTF.
- [ ] Code passes PHPCS, PHPMD, PHPStan
- [ ] Code is properly documented (PHPDoc)
- [ ] No debugging code (
var_dump,console.log) - [ ] All new features are covered by tests
- [ ] No security vulnerabilities (XSS, CSRF, SQL injection)
- [ ] Backward-compatible changes
21.6 Logging and Debugging
Custom logging:
<?php
namespace MyCompany\HelloWorld\Model;
use Psr\Log\LoggerInterface;
class MyService
{
public function __construct(
private readonly LoggerInterface $logger
) {}
public function doSomething()
{
try {
$this->logger->info('Operation performed', ['context' => 'value']);
} catch (\Exception $e) {
$this->logger->error('Failed: ' . $e->getMessage(), [
'trace' => $e->getTraceAsString()
]);
}
}
}
Log levels: emergency(), alert(), critical(), error(), warning(), notice(), info(), debug()
View logs in real time:
tail -f var/log/system.log
tail -f var/log/exception.log
Template hints:
php bin/magento dev:template-hints:enable
php bin/magento dev:template-hints:disable
Profiling:
php bin/magento dev:profiler:enable
php bin/magento dev:profiler:disable
Chapter 22: Career and Certification
22.1 Magento Interview Preparation
Core technical questions:
Q: What is the difference between a plugin, an observer, and a preference?
Plugins (interceptors) modify specific public methods’ behaviour — they can be chained and are non-invasive. Observers listen to events dispatched by the system and execute code in response — loosely coupled, multiple modules can react to the same event. Preferences replace an entire class with a custom implementation — invasive and should be used sparingly, as only one preference can be active per class.
Q: How does Dependency Injection work in Magento?
Magento reads all di.xml files from every module, builds a dependency graph, and resolves dependencies automatically via the Object Manager. When a class declares a type-hinted constructor parameter, Magento finds the corresponding concrete class (via di.xml preferences) and injects it automatically.
Q: What is the difference between setup:upgrade and setup:di:compile?
setup:upgrade runs database schema and data patches and updates the setup_module table. setup:di:compile generates all factories, proxies, and interceptors and compiles the dependency injection configuration into a cache.
Q: How would you handle a slow Magento store?
- Enable template hints to find slow blocks
- Enable profiler (
bin/magento dev:profiler:enable) - Check
var/log/system.logfor slow SQL queries - Use Blackfire to profile the page
- Implement quick wins: enable Varnish FPC, Redis for sessions/cache, merge CSS/JS
- Fix code issues: reduce N+1 queries, limit collection sizes, add database indexes
- Scale infrastructure: add read replicas, auto-scaling web servers, CDN for static assets
Coding challenge — create a module displaying “Hello World”:
Expected steps: create registration.php and etc/module.xml → create etc/frontend/routes.xml → create Controller/Index/Index.php returning PageFactory → create layout file → create template → run module:enable, setup:upgrade, cache:flush.
22.2 Adobe Certification Preparation
Certification paths:
| Certification | Target Role | Exam Code |
|---|---|---|
| Adobe Certified Expert — Adobe Commerce Developer | Developer | AD0-E703 |
| Adobe Certified Expert — Adobe Commerce Front-End Developer | Frontend Developer | AD0-E708 |
| Adobe Certified Master — Adobe Commerce Architect | Solution Architect | AD0-E709 |
Developer certification exam topics:
| Topic | Weight |
|---|---|
| Architecture (DI, modules, service contracts, caching) | 20% |
| Database (EAV, declarative schema, patches, indexing) | 15% |
| Backend Development (plugins, observers, cron, CLI, admin, ACL) | 20% |
| Frontend Development (themes, layout XML, blocks, templates, JS) | 15% |
| Catalog & Checkout: Covers product types, inventory management, quotes, and order processing. | 15% |
| API & Integrations (REST, GraphQL, webhooks) | 10% |
| Testing & Performance | 5% |
Architect certification exam topics:
| Topic | Weight |
|---|---|
| Design Architecture (enterprise design, scalability, HA) | 25% |
| Customisation (module architecture, extension guidelines) | 20% |
| Integrations (ERP, CRM, payment gateways, shipping) | 20% |
| Cloud & DevOps (Adobe Commerce Cloud, CI/CD) | 15% |
| Performance (caching, indexing, scaling) | 10% |
| Security (secure coding, ACL, PCI compliance) | 10% |
22.3 Portfolio Development
What to include:
| Item | Description |
|---|---|
| Custom Modules | Showcase module development with good documentation |
| Theme | Custom theme with design decisions explained |
| Integrations | ERP, CRM, payment gateway integrations |
| Performance Case Studies | Before/after metrics from optimisation projects |
| Open Source Contributions | Pull requests to Magento core or extensions |
Project description template:
## Project: [Name]
**Client:** [Client Name or "Personal Project"]
**Role:** [Backend Developer / Solution Architect / etc.]
**Technologies:** Magento 2.4.x, PHP 8.2, MySQL, Redis, Elasticsearch
**Description:** [2-3 sentences describing the project]
**Key Features:**
- [Feature 1]
- [Feature 2]
**Challenges:** [What was difficult]
**Results:** [Measurable outcomes]
**Link:** [Live site or GitHub repository]
22.4 Career Path — Becoming a Solution Architect
Career progression:
Stage 1 — Developer (2-4 years):
- Learn core Magento architecture
- Build custom modules and extensions
- Write unit and integration tests
- Master PHP OOP and SOLID principles
Stage 2 — Senior Developer / Technical Lead (3-5 years):
- Lead development teams
- Design solutions for complex requirements
- Mentor junior developers
- Performance optimisation and security hardening
Stage 3 — Solution Architect (5+ years):
- Design large-scale enterprise systems
- Define technical direction and standards
- Work directly with clients/stakeholders on architecture
- Lead commerce transformation programs
Success principles for Solution Architects:
- Understand the business — align technology with business goals
- Communicate effectively — explain technical concepts to non-technical stakeholders
- Make pragmatic trade-offs — balance cost, time, and quality
- Stay current — follow Magento developments, attend conferences
- Build relationships — trust with clients, network with professionals
22.5 Open Source Contribution
Getting started:
# Fork Magento repository on GitHub, then clone
git clone git@github.com:yourusername/magento2.git
# Create a feature branch
git checkout -b bugfix/ISSUE-12345-fix-product-price
# Make changes, then commit with clear message
git add .
git commit -m "Fix product price calculation for tier prices in configurable products"
# Push and open pull request
git push origin bugfix/ISSUE-12345-fix-product-price
Ways to contribute:
- Fix bugs in the issue tracker
- Write or improve documentation
- Add or improve translations
- Review pull requests
- Answer questions on Stack Overflow or community Slack
22.6 Essential CLI Reference
# ─── Module Management ───────────────────────────────────────
php bin/magento module:enable Vendor_Module
php bin/magento module:disable Vendor_Module
php bin/magento module:status
# ─── Setup & Compilation ────────────────────────────────────
php bin/magento setup:upgrade
php bin/magento setup:di:compile
php bin/magento setup:static-content:deploy -f
php bin/magento setup:static-content:deploy en_US de_DE fr_FR
# ─── Cache Management ───────────────────────────────────────
php bin/magento cache:flush
php bin/magento cache:clean
php bin/magento cache:status
php bin/magento cache:disable layout block_html
php bin/magento cache:enable layout
# ─── Indexer Management ─────────────────────────────────────
php bin/magento indexer:reindex
php bin/magento indexer:reindex catalog_product_price
php bin/magento indexer:status
php bin/magento indexer:set-mode schedule
php bin/magento indexer:set-mode realtime
# ─── Mode Management ────────────────────────────────────────
php bin/magento deploy:mode:set developer
php bin/magento deploy:mode:set production
php bin/magento deploy:mode:show
# ─── Configuration ──────────────────────────────────────────
php bin/magento config:set web/unsecure/base_url http://localhost/
php bin/magento config:show web/unsecure/base_url
php bin/magento config:set --scope=stores --scope-code=default \
mycompany_blog/general/enabled 1
# ─── Admin User ─────────────────────────────────────────────
php bin/magento admin:user:create \
--admin-user=admin --admin-password=Admin123! \
--admin-email=admin@example.com \
--admin-firstname=Admin --admin-lastname=User
# ─── Cron ───────────────────────────────────────────────────
php bin/magento cron:run
php bin/magento cron:install
php bin/magento cron:list
# ─── Maintenance Mode ───────────────────────────────────────
php bin/magento maintenance:enable
php bin/magento maintenance:disable
php bin/magento maintenance:allow-ips 192.168.1.1
# ─── Developer Tools ────────────────────────────────────────
php bin/magento dev:template-hints:enable
php bin/magento dev:template-hints:disable
php bin/magento dev:profiler:enable
php bin/magento dev:profiler:disable
php bin/magento i18n:collect-phrases -o output.csv app/code/MyCompany/Blog
# ─── Queue (RabbitMQ) ───────────────────────────────────────
php bin/magento queue:consumers:list
php bin/magento queue:consumers:start async.operations.all
# ─── Catalog & Images ───────────────────────────────────────
php bin/magento catalog:images:resize
php bin/magento catalog:product:attributes:cleanup
# ─── Sample Data ────────────────────────────────────────────
php bin/magento sampledata:deploy
php bin/magento sampledata:remove
Final Words
You have now completed this comprehensive journey from e-commerce foundations to Magento Solution Architect. You have learned:
- Foundations: Commerce history, models (B2C/B2B/D2C/Marketplace/Subscription), and digital commerce architecture. The complete development environment setup across Windows, Linux, macOS, Docker, Warden, and DDEV. PHP OOP, SOLID principles, and design patterns that power Magento.
- Core Magento: The MVC architecture, dependency injection, request lifecycle, module system, and service contracts. The bootstrap process, generated code (factories, proxies, interceptors), and application areas.
- Database: Declarative schema, schema patches, data patches, EAV architecture, models, resource models, collections, and repositories. How to create custom product attributes and custom database tables.
- Extension methods: Events/observers, plugins (before/after/around), and preferences — and critically, when to use each.
- Admin development: Admin routes, controllers, menus, ACL, dashboards, and UI components (grids, forms, data providers, mass actions).
- Frontend: Theme development, layout XML, blocks, templates, view models, LESS/CSS, RequireJS, KnockoutJS, Hyvä, and PWA Studio.
- Commerce systems: Catalog architecture, all seven product types, category management, MSI inventory, customer management, checkout, quote system, sales architecture, custom payment gateways, custom shipping carriers, promotions, and tax.
- APIs: REST endpoints with webapi.xml, GraphQL schemas and resolvers, webhooks, third-party API integration, and security best practices.
- Testing and quality: PHPUnit unit tests, integration tests, MFTF functional tests, API testing, PHPCS, PHPStan, and code review practices.
- Performance: Cache architecture (Redis, Varnish, FPC), Elasticsearch/OpenSearch, indexers, query optimisation, CDN integration, and the production deployment checklist.
- Security: Authentication, XSS/CSRF/SQL injection prevention, ACL, 2FA, reCAPTCHA, encryption, security patches, and PCI compliance basics.
- DevOps: Docker, Docker Compose, RabbitMQ message queues, cron jobs, GitHub Actions CI/CD, Kubernetes, and Adobe Commerce Cloud.
- Enterprise features: Multi-store, multi-language, multi-currency, B2B commerce, headless commerce, event-driven architecture, and microservices.
- AI commerce: Product recommendations, AI search (Live Search), personalisation, chatbot integration (OpenAI/GPT-4), and AI-powered admin tools.
- Solution Architect track: Enterprise system design, scalability patterns, high availability, load balancing, disaster recovery, multi-region deployments, architecture reviews, ADRs, and technical leadership.
- Career: Interview preparation, Adobe certification, portfolio development, freelancing, open source contribution, and personal branding.
The mark of a senior Magento developer or Solution Architect is not memorising every API — it is recognising which pattern applies to a new requirement, even one never seen before, because the foundational patterns become second nature through deliberate practice.
Your next steps:
- Build a portfolio — create a personal website showcasing your projects
- Contribute to open source — submit a pull request to Magento core
- Get certified — prepare for Adobe Commerce Developer certification (AD0-E703)
- Network — join the Magento community on Slack, Stack Overflow, and at conferences
- Apply — start applying for roles that match your career goals
Welcome to the world of enterprise commerce. You have the knowledge. Now go build something remarkable.
