Magento 2

  1. PART ONE: MAGENTO FOUNDATIONS & ENVIRONMENT
    1. Chapter 1: Introduction to E-Commerce & Magento
      1. 1.1 What is Magento?
      2. 1.2 Types of Magento: Open Source vs. Adobe Commerce
      3. 1.3 History and Evolution of E-Commerce
      4. 1.4 Commerce Models (B2B, B2C, D2C etc.)
      5. 1.5 Real-World Use Cases and Industry Applications
      6. 1.6 Modern Commerce Trends
    2. Chapter 2: Setting Up Your Development Environment
      1. 2.1 Prerequisites — What You Must Install Before Magento
        1. 2.1.1 PHP 8.2 with Required Extensions
        2. 2.1.2 Composer – Dependency Management
        3. 2.1.3 MySQL – Database Fundamentals
        4. 2.1.4 OpenSearch (or Elasticsearch) — The Search Engine
        5. 2.1.5 Redis — Cache and Session Storage
        6. 2.1.6 RabbitMQ — Message Queue (Optional but Used by B2B)
        7. 2.1.7 Git — Version Control
        8. 2.1.8 Basic Web Concepts
        9. 2.1.9 Recommended Development Tools
      2. 2.2 Linux Setup — Complete Native Nginx + PHP-FPM + MySQL
        1. 2.2.1 Update System and Install Nginx
        2. 2.2.2 Start PHP-FPM
        3. 2.2.3 Magento Installation (Step-by-Step)
        4. 2.2.4 Linux – Apache Setup (Alternative)
        5. 2.2.5 Installing Magento with Sample Data (Optional)
      3. 2.3 Windows Setup — Five Methods
        1. 2.3.1 WSL2 (Recommended)
        2. 2.3.2 XAMPP (Direct Setup)
        3. 2.3.3 Chocolatey + Manual Setup (Advanced)
      4. 2.4 macOS Setup
        1. 2.4.1 Homebrew (Nginx + PHP-FPM + MySQL)
        2. 2.4.2 MAMP
      5. 2.5 Docker Setup (Cross-Platform)
        1. 2.5.1 Install Docker
        2. 2.5.2 Create docker-compose.yml
        3. 2.5.3 Start containers
        4. 2.5.4 Install Magento inside the PHP container
      6. 2.6 Warden (Magento-specific Docker Orchestration)
      7. 2.7 DDEV
      8. 2.8 Magento PWA Studio Setup
      9. 2.9 IDE Configuration
        1. 2.9.1 PhpStorm
        2. 2.9.2 VS Code
      10. 2.10 Xdebug — Step Debugging
        1. 2.10.1 Install Xdebug
        2. 2.10.2 Configure Xdebug
        3. 2.10.3 Set up IDE
      11. 2.11 Post-Installation Configuration, File Permissions & Mode Setup (Applicable to All Environments)
        1. 2.11.1 Set correct file permissions (Linux/macOS/WSL)
        2. 2.11.2 Enable developer mode
        3. 2.11.3 Flush caches
        4. 2.11.4 Reindex all indexers
        5. 2.11.5 Compile dependency injection (optional in developer mode)
        6. 2.11.6 Verify installation
      12. 2.12 Environment Setup: PhpStorm, Composer, MySQL Workbench, Postman
        1. 2.12.1 Composer configuration (global)
        2. 2.12.2 MySQL Workbench
        3. 2.12.3 Postman
      13. 2.13 Installation Methods (Summary)
      14. 2.14 Troubleshooting Common Issues
      15. 2.15 Summary
    3. Chapter 3: Magento Architecture & Internal Working
      1. 3.1 High-Level Architecture Overview (MVC, Modules, Service Contracts)
      2. 3.2 Dependency Injection (DI) – Conceptual Overview
      3. 3.3 Request Flow & Execution Pipeline
      4. 3.4 Module System and Folder Structure
      5. 3.5 Detailed Project Folder Structure
      6. 3.6 Design Patterns in Magento
  2. PART TWO: BACKEND DEVELOPMENT & CORE SYSTEMS
    1. Chapter 4: PHP Foundations for Magento
      1. 4.1 Variables and Types
      2. 4.2 Arrays
      3. 4.3 Classes and Objects
      4. 4.4 Interfaces
      5. 4.5 Traits
      6. 4.6 Namespaces
      7. 4.7 Error Handling and Exceptions
      8. 4.8 SOLID Principles
      9. 4.9 Design Patterns
    2. Chapter 5: Your First Magento Module — Hello World
      1. 5.1 Creating the Module Folder Structure
      2. 5.2 registration.php
      3. 5.3 module.xml
      4. 5.4 composer.json (Optional but Recommended)
      5. 5.5 Routes
      6. 5.6 Controller
      7. 5.7 Layout XML
      8. 5.8 Block Class
      9. 5.9 Template
      10. 5.10 Deployment
    3. Chapter 6: Beginner Magento Concepts & Admin Panel
      1. 6.1 Configuration Basics (system.xml, config.xml)
      2. 6.2 Understanding Modules (Lifecycle, Dependencies, Sequencing)
      3. 6.3 Magento CLI Basics – Complete Command Reference
      4. 6.4 Themes, Templates, and Layout XML Overview
      5. 6.5 Magento Database Structure: EAV Model and Core Tables
      6. 6.6 Admin Panel Overview
    4. Chapter 7: Core Extension Methods (Plugins, Preferences, Observers)
      1. 7.1 Dependency Injection – Full Deep Dive
        1. 7.1.1 What is Dependency Injection?
        2. 7.1.2 Constructor Injection – The Only Injection Method
        3. 7.1.3 di.xml – The Configuration File for DI
        4. 7.1.4 Object Manager – Never Use Directly
        5. 7.1.5 How DI Works Internally
      2. 7.2 Plugins (Interceptors)
        1. 7.2.1 What is a Plugin?
        2. 7.2.2 Three Types of Plugin Methods
        3. 7.2.3 Real Example: Plugin on Product::getName() and setName()
        4. 7.2.4 Important Rules for Plugins
        5. 7.2.5 Real Use Case: Validating Product SKU
      3. 7.3 Preferences – Class Overrides
        1. 7.3.1 Definition
        2. 7.3.2 When to Use Preferences (Rarely)
        3. 7.3.3 Real Example – Adding a New Method to Product
        4. 7.3.4 Warning: Preferences Break if Overridden
      4. 7.4 Observers & Events
        1. 7.4.1 Definition
        2. 7.4.2 Registering an Observer in events.xml
        3. 7.4.3 Creating an Observer Class
        4. 7.4.4 Dispatching Your Own Custom Event
        5. 7.4.5 List of Common Core Events
      5. 7.5 Comparison & Decision Guide: Plugin vs. Preference vs. Observer
    5. Chapter 8: Intermediate Backend Development
      1. 8.1 Routing & Controllers (Frontend & Admin)
        1. 8.1.1 Frontend Routing
        2. 8.1.2 Frontend Controller
        3. 8.1.3 Adminhtml Routing
      2. 8.2 Blocks and Templates (PHTML)
        1. 8.2.1 Creating a Block
        2. 8.2.2 Creating a Template
        3. 8.2.3 Associating Block and Template via Layout
      3. 8.3 UI Components for Admin Grids and Forms
        1. 8.3.1 Admin Grid UI Component Example
        2. 8.3.2 Actions Column Class
      4. 8.4 Creating a Complete CRUD Module (Blog Module)
        1. 8.4.1 Module Registration (Quick Recap)
        2. 8.4.2 Database Schema (Declarative Schema)
        3. 8.4.3 Data Interface (Service Contract)
        4. 8.4.4 Model
        5. 8.4.5 Resource Model
        6. 8.4.6 Collection
        7. 8.4.7 Repository Interface
        8. 8.4.8 Repository Implementation
        9. 8.4.9 di.xml – Preferences
        10. 8.4.10 REST API (webapi.xml)
        11. 8.4.11 Admin Grid UI Component
        12. 8.4.12 Frontend Listing Page
      5. 8.5 Frontend Styling (LESS, CSS, SCSS) & Layout System Deep Dive
        1. 8.5.1 Adding CSS/LESS in a Module
        2. 8.5.2 Layout System Deep Dive
      6. 8.6 Magento Widgets, Catalog Management, Customer Management, Sales & Checkout Flow
        1. 8.6.1 Magento Widgets
        2. 8.6.2 Catalog Management
        3. 8.6.3 Customer Management
        4. 8.6.4 Sales & Checkout Flow
    6. Chapter 9: Models, Resource Models, Collections, Blocks & Complete CRUD Module
      1. 9.1 Understanding the Magento Data Layer — Why Three Separate Classes?
      2. 9.2 The Model — Deep Explanation
        1. 9.2.1 What a Model Actually Is
        2. 9.2.2 What the Model Is Responsible For
        3. 9.2.3 What the Model Is NOT Responsible For
        4. 9.2.4 Anatomy of a Model Class — Every Line Explained
        5. 9.2.5 How the Model’s Inherited Methods Actually Work
        6. 9.2.6 Adding Custom Business Logic to a Model
      3. 9.3 The Resource Model — Deep Explanation
        1. 9.3.1 What a Resource Model Actually Is
        2. 9.3.2 What the Resource Model Is Responsible For
        3. 9.3.3 What the Resource Model Is NOT Responsible For
        4. 9.3.4 Anatomy of a Resource Model Class — Every Line Explained
        5. 9.3.5 What Happens Internally When _init() Is Called
        6. 9.3.6 Adding Custom Database Logic to a Resource Model
      4. 9.4 The Collection — Deep Explanation
        1. 9.4.1 What a Collection Actually Is
        2. 9.4.2 What the Collection Is Responsible For
        3. 9.4.3 What the Collection Is NOT Responsible For
        4. 9.4.4 Anatomy of a Collection Class — Every Line Explained
        5. 9.4.5 How Collection Methods Work Internally
        6. 9.4.6 Common Collection Filter Methods Explained
      5. 9.5 The Block — Deep Explanation
        1. 9.5.1 What a Block Actually Is
        2. 9.5.2 What the Block Is Responsible For
        3. 9.5.3 What the Block Is NOT Responsible For
        4. 9.5.4 Anatomy of a Block Class — Every Line Explained
        5. 9.5.5 How the Block Connects to the Template — The Full Chain
        6. 9.5.6 The $escaper Object — Why It Exists
      6. 9.6 Complete CRUD Module — Built From Scratch, Every File Explained
        1. 9.6.1 The Complete File Map
        2. 9.6.2 Module Registration Files
        3. 9.6.3 Database Schema — etc/db_schema.xml
        4. 9.6.4 The Data Interface (Service Contract) — Api/Data/StockItemInterface.php
        5. 9.6.5 The Model Implementing the Interface — Model/StockItem.php
        6. 9.6.6 The Resource Model — Model/ResourceModel/StockItem.php
        7. 9.6.7 The Collection — Model/ResourceModel/StockItem/Collection.php
        8. 9.6.8 The Repository Interface — Api/StockItemRepositoryInterface.php
        9. 9.6.9 The Search Results Interface — Api/Data/StockItemSearchResultsInterface.php
        10. 9.6.10 The Repository Implementation — Model/StockItemRepository.php
        11. 9.6.11 Dependency Injection Configuration — etc/di.xml
        12. 9.6.12 ACL Permissions — etc/acl.xml
        13. 9.6.13 Admin Routes — etc/adminhtml/routes.xml
        14. 9.6.14 Admin Menu — etc/adminhtml/menu.xml
        15. 9.6.15 Admin Controllers — One File Per Action
        16. 9.6.16 Admin UI Component — Grid (view/adminhtml/ui_component/mycompany_inventory_stockitem_listing.xml)
        17. 9.6.17 The UI Component Data Provider — Ui/DataProvider/StockItemDataProvider.php
        18. 9.6.18 The Actions Column Class — Ui/Component/Listing/Column/StockItemActions.php
        19. 9.6.19 Admin Form UI Component — view/adminhtml/ui_component/mycompany_inventory_stockitem_form.xml
        20. 9.6.20 The Save Button Block — Block/Adminhtml/StockItem/Edit/SaveButton.php
        21. 9.6.21 Layout for the Edit Page — view/adminhtml/layout/mycompany_inventory_stockitem_edit.xml
        22. 9.6.22 Frontend — Route, Controller, Layout, Block, Template
        23. 9.6.23 REST API — etc/webapi.xml
        24. 9.6.24 Deployment — Putting It All Together
  3. PART THREE: ADVANCED SYSTEMS & PERFORMANCE
    1. Chapter 10: Events, Observers & Plugins
      1. 10.1 Event System
      2. 10.2 Custom Events
      3. 10.3 Observers
      4. 10.4 Plugins (Interceptors)
        1. 10.4.1 Before Plugin
        2. 10.4.2 After Plugin
        3. 10.4.3 Around Plugin
        4. 10.4.4 Register the Plugin in di.xml
      5. 10.5 Preferences — Class Overrides
      6. 10.6 Extension Strategies — Decision Guide
    2. Chapter 11: Admin Development
      1. 11.1 Admin Routes
      2. 11.2 Admin Controllers
      3. 11.3 Admin Menus
      4. 11.4 ACL Resources
      5. 11.5 Admin Dashboards
      6. 11.6 UI Components — Admin Grids and Forms
    3. Chapter 12: Frontend Development
      1. 12.1 Theme Development
      2. 12.2 Layout XML — Deep Dive
      3. 12.3 Blocks and Templates
      4. 12.4 View Models
      5. 12.5 LESS and CSS
      6. 12.6 JavaScript Architecture
      7. 12.7 Hyvä Theme Development
      8. 12.8 PWA Studio — Headless Magento Storefront
    4. Chapter 13: Commerce Systems
      1. 13.1 Catalog Architecture
      2. 13.2 Product Types
      3. 13.3 Category Management
      4. 13.4 Inventory Management (MSI)
      5. 13.5 Customer Management
      6. 13.6 Checkout Architecture
      7. 13.7 Sales Architecture
      8. 13.8 Custom Payment Gateway
      9. 13.9 Custom Shipping Carrier
      10. 13.10 Promotions and Marketing
    5. Chapter 14: API Development & Integrations
      1. 14.1 API Overview – REST, GraphQL, Webhooks, and API Consumers
        1. 14.1.1 REST API
        2. 14.1.2 GraphQL API
        3. 14.1.3 Webhooks
        4. 14.1.4 API Consumers (Integrations)
      2. 14.2 Creating Custom REST API Endpoints (webapi.xml)
      3. 14.3 Developing Custom GraphQL Schemas & Resolvers
      4. 14.4 Integrating Third-Party APIs
      5. 14.5 API Best Practices & Security
    6. Chapter 15: Performance Engineering
      1. 15.1 Cache Architecture
      2. 15.2 Redis Configuration
      3. 15.3 Varnish Configuration
      4. 15.4 Elasticsearch and OpenSearch
      5. 15.5 Indexers
      6. 15.6 Query Optimisation
      7. 15.7 High Performance Magento Checklist
    7. Chapter 16: Security Engineering
      1. 16.1 Authentication
      2. 16.2 Secure Coding Practices
      3. 16.3 ACL and Authorization
      4. 16.4 Two-Factor Authentication (2FA)
      5. 16.5 reCAPTCHA
      6. 16.6 Security Patches
      7. 16.7 PCI Compliance Basics
  4. PART FOUR: DEVOPS & ENTERPRISE
    1. Chapter 17: DevOps and Cloud
      1. 17.1 Docker
      2. 17.2 RabbitMQ — Message Queues
      3. 17.3 Cron Jobs
      4. 17.4 CI/CD
      5. 17.5 Kubernetes
      6. 17.6 Adobe Commerce Cloud
    2. Chapter 18: Enterprise Commerce
      1. 18.1 Multi-Store Architecture
      2. 18.2 Multi-Language Stores
      3. 18.3 Multi-Currency Stores
      4. 18.4 B2B Commerce
      5. 18.5 Headless Commerce
    3. Chapter 19: AI Commerce
      1. 19.1 AI Product Recommendations
      2. 19.2 AI Search (Adobe Commerce Live Search)
      3. 19.3 AI Chatbot Integration
      4. 19.4 ChatGPT and Claude Integration
    4. Chapter 20: Solution Architect Track
      1. 20.1 Enterprise System Design
      2. 20.2 Scalability Patterns
      3. 20.3 High Availability
      4. 20.4 Load Balancing
      5. 20.5 Disaster Recovery
      6. 20.6 Technical Leadership
  5. PART FIVE: TESTING, CAREER & PROFESSIONAL DEVELOPMENT
    1. Chapter 21: Testing and Code Quality
      1. 21.1 PHPUnit — Unit Tests
      2. 21.2 Integration Tests
      3. 21.3 MFTF — Magento Functional Testing Framework
      4. 21.4 API Testing
      5. 21.5 Code Quality Tools
      6. 21.6 Logging and Debugging
    2. Chapter 22: Career and Certification
      1. 22.1 Magento Interview Preparation
      2. 22.2 Adobe Certification Preparation
      3. 22.3 Portfolio Development
      4. 22.4 Career Path — Becoming a Solution Architect
      5. 22.5 Open Source Contribution
      6. 22.6 Essential CLI Reference
      7. Final Words

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

FeatureMagento Open SourceAdobe Commerce
PriceFreePaid subscription
B2B featuresNoCompany accounts, requisition lists, negotiated quotes
Staging & previewNoScheduled updates, content staging
Advanced promotionsBasicCustomer segments, loyalty
ReportingBasicAdvanced BI, dashboards
SupportCommunity24/7 Adobe support
Cloud deploymentSelf-hostedAdobe Commerce Cloud
Page BuilderNoYes

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

IndustryExample BrandsWhy Magento?
Fashion & ApparelExamples 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.
ElectronicsSamsung, CanonThousands of SKUs, advanced faceted search
B2B WholesaleGrainger, WurthCompany accounts, requisition lists, tiered pricing
Luxury GoodsBulgari, MontblancHigh-end imagery, custom checkout
Food & BeverageNestlé, Coca-ColaSubscription 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.

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:

ExtensionPurpose
bcmathArbitrary‑precision math for price calculations where floating‑point rounding errors would be unacceptable.
curlOutbound HTTP requests for payment gateways, shipping carrier APIs, third‑party integrations.
gdImage processing for product photo resizing and watermarking.
intlInternationalisation, currency symbols, number formatting for multi‑language stores.
mbstringCorrectly handles multi‑byte characters for non‑English product names.
pdo_mysqlThe database driver (MySQL).
soapUsed by some legacy payment/shipping integrations and B2B services.
xml, xsl, zipEssential for XML handling and compression.
opcacheImproves 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.

  1. 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).
  2. Extract the ZIP to C:\php. Ensure that C:\php\php.exe exists directly inside, not in a subfolder like C:\php\php-8.2.20\.
  3. 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.
  1. Configure php.ini:
  • In C:\php, copy php.ini-development and rename the copy to php.ini.
  • Open php.ini in 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.
  1. Verify:
  • Open a new Command Prompt (to pick up the PATH changes).
  • Run php -v – you should see PHP 8.2.
  • Run php -m and 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

  1. Download the MySQL Installer from dev.mysql.com.
  2. Run the installer and choose Developer Default (which includes MySQL Server, Workbench, etc.).
  3. 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.
  1. 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:

ConceptDescription
HTTP methodsGET, POST, PUT, DELETE
Status codes200 (OK), 301 (redirect), 404 (Not Found), 500 (Internal Server Error)
Cookies and sessionsHow Magento tracks shopping carts and customer logins
REST APIsEndpoints, request/response structure, authentication (bearer tokens)
CachingWhat it is and why it matters (Full Page Cache, Redis, Varnish)
DNS and hosts filesHow domain names resolve to IP addresses; the hosts file overrides DNS locally.
ToolPurpose
PhpStormIDE with Magento plugin (recommended)
VS CodeLightweight alternative with PHP extensions
MySQL WorkbenchVisual database management
PostmanTest REST APIs
DockerContainerized environment (cross‑platform)
XdebugStep‑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, run sudo 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 find commands add group write permissions (g+w) to files and directories that Magento needs to write to at runtime (cache, logs, media, static files). The g+ws sets 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:

  • upstream defines the PHP‑FPM backend (using the socket).
  • server_name – we’ll use magento.local as the domain.
  • $MAGE_ROOT – points to the Magento root.
  • The include directive 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 with admin / 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 var and pub/static are 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.

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\magento to 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.

  1. Download and install MAMP from mamp.info.
  2. Launch MAMP, go to Preferences → PHP and select PHP 8.2.
  3. Click Start Servers.
  4. By default, the web root is /Applications/MAMP/htdocs.
  5. Install Magento there:
   cd /Applications/MAMP/htdocs
   composer create-project --repository-url=https://repo.magento.com/ magento/project-community-edition magento
  1. Create database in phpMyAdmin (accessible via http://localhost:8888/phpmyadmin) or via command line (MAMP’s MySQL uses port 8889).
  2. Run installer with --db-host=127.0.0.1 --db-port=8889 --base-url=http://localhost:8888/ and similar adjustments.
  3. 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

  1. Install PhpStorm from JetBrains.
  2. Install the Magento PhpStorm plugin:
  • Go to Settings → Plugins.
  • Search for “Magento” and install the official plugin (by JetBrains or Magento).
  1. Open your Magento project: File → Open → select the Magento root folder.
  2. Enable Magento 2 support:
  • Go to Settings → Languages & Frameworks → PHP → Magento.
  • Check “Enable Magento 2 support”.
  • Set “Magento Root” to your project root.
  1. Set PHP interpreter: Go to Settings → Languages & Frameworks → PHP and choose the PHP 8.2 interpreter (if not detected, add it).
  2. Configure Code Style: You can import Magento Coding Standard (PSR-12 based) from the project or download it.
  3. 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

  1. Install PHP Intelephense (by Ben Mewburn) for advanced code intelligence.
  2. Install PHP Debug (by Felix Becker) for Xdebug integration.
  3. Open the Magento folder.
  4. For Xdebug, create a launch configuration:
  • Create .vscode/launch.json with:
   {
       "version": "0.2.0",
       "configurations": [
           {
               "name": "Listen for Xdebug",
               "type": "php",
               "request": "launch",
               "port": 9003
           }
       ]
   }
  1. 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 -i into the text box.
  • Download the recommended DLL file and place it in C:\php\ext.
  • Edit php.ini and 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 --ini to 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/rest
  • admin_token etc.

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.

ProblemLikely causeSolution
“Access denied” for MySQLWrong username/password or privileges not granted.Re‑run CREATE USER and GRANT as root.
OpenSearch not respondingContainer not started or still initialising.Wait 30s; check logs with docker logs opensearch.
Composer 401 UnauthorizedInvalid Magento access keys.Re‑check keys and global config; run composer global config -l to verify.
White screen after installPermissions 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 GatewayPHP-FPM is not running or socket path wrong.Restart PHP-FPM; verify socket exists and is readable by Nginx.
Static files (CSS/JS) not loadingStatic content deployment needed.Run php bin/magento setup:static-content:deploy -f (in developer mode).
Admin URL not workingWrong 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:

ComponentRole in MagentoTypical Location
ModelBusiness logic, database interactions, data managementVendor/Module/Model/
ViewRenders UI using layout XML and PHTML templatesVendor/Module/view/frontend/layout/ and templates/
ControllerAccepts HTTP requests, calls models/services, returns resultsVendor/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/ or generated/ — they are managed by Composer and Magento
  • pub/ is the only folder that should be exposed to the web
  • app/etc/env.php contains 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 as shared="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 implementations
  • Controller — for controllers
  • Block — for blocks
  • Setup — for installation and upgrade scripts

4.7 Error Handling and Exceptions

Magento’s exception hierarchy:

  • LocalizedException — for business logic errors to display to users
  • NoSuchEntityException — when a requested entity doesn’t exist
  • InputException — for validation errors
  • CouldNotSaveException — 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. ProductRepository is 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 AbstractProduct and override a method, the method must still fulfil the same contract.
  • Interface Segregation (ISP) — Many small interfaces are better than one large one. ProductRepositoryInterface only has get, 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.

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.log for class not found errors
  • Wrong version showing: run bin/magento setup:di:compile to 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 ObjectManager directly
  • 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 attributes
  • catalog_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:

TablePurpose
catalog_product_entityBase product info (sku, created_at)
catalog_product_entity_varcharText values for product attributes
catalog_product_entity_intInteger values
catalog_product_entity_decimalDecimal values (price, weight)
eav_attributeAll attribute definitions
customer_entityCustomer base info
sales_orderOrder headers
sales_order_itemOrder line items
quoteShopping cart
storeStore views
admin_userAdmin users
cron_scheduleCron 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 write new 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

  1. Magento reads all di.xml files from every module and merges them
  2. It builds a dependency graph – for each class, it determines what arguments are needed
  3. For each interface, it finds the preference (concrete class)
  4. When a class is instantiated, the DI container recursively creates all its dependencies
  5. The generated code is cached in generated/ (factories, interceptors)
  6. 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:

  • final methods or final classes
  • Static methods
  • Constructors (__construct)
  • Non-public methods

7.2.2 Three Types of Plugin Methods

TypeMethod NamingWhen it RunsTypical Use
BeforebeforeMethodName()Before the original methodValidate or modify arguments
AfterafterMethodName()After the original methodModify the return value
AroundaroundMethodName()Wraps the original methodAdd 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

  • before methods: Must return an array of arguments with the same count as the original method
  • after methods: Must return a value (the modified result)
  • around methods: Must accept callable $proceed as 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 NameWhen DispatchedAvailable Data
catalog_product_save_afterAfter product savedproduct
checkout_cart_add_product_completeAfter product added to cartproduct
sales_order_place_afterAfter order placedorder
customer_register_successAfter customer registrationcustomer
controller_action_predispatchBefore any controller actioncontroller_action
cms_page_renderBefore rendering CMS pagepage

7.5 Comparison & Decision Guide: Plugin vs. Preference vs. Observer

What you need to doBest solutionWhy
Modify arguments before a method is calledPlugin (before)Clean, multiple modules can coexist
Modify the return value of a methodPlugin (after)Clean, does not break other modules
Add behaviour both before and after a methodPlugin (around)Use sparingly
Add a completely new method to a classPreferencePlugins cannot add methods
Change the constructor signaturePreference or virtual typeVirtual type is often better
React to something that happenedObserverDoes 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_view event
  • 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 ProductRepositoryInterface to load, save, delete. Use ProductFactory to 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: CustomerRepositoryInterface to load/save customers.
  • Customer Account: Use AccountManagementInterface for 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:

ClassResponsibilityAnalogy
ModelRepresents ONE business entity and its data/behaviourThe actual product itself — its name, price, the rules about what makes a valid product
Resource ModelHandles HOW that one entity is saved/loaded/deleted from the databaseThe warehouse worker who physically moves the product in and out of storage
CollectionHandles loading and filtering MULTIPLE entities at onceThe 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 after AbstractModel‘s real constructor finishes
  • $this->_init(ItemResource::class); — Tells the Model “when someone calls save(), load(), or delete() on me, delegate that work to the ItemResource Resource Model class”

9.2.5 How the Model’s Inherited Methods Actually Work

When you write $item->load(5), here is what happens internally:

  1. load() is a method defined in AbstractModel (inherited)
  2. AbstractModel::load($id) calls $this->getResource()->load($this, $id)
  3. getResource() returns an instance of ItemResource
  4. The Resource Model’s load() method runs the actual SQL SELECT query
  5. The Resource Model populates the $item object with the row’s data
  6. Control returns to your code, and $item now 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 Select object
  • 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

  1. Layout XML → declares which Block class + which template file to use
  2. ↓
    Magento’s Layout system → instantiates the Block class via DI
  3. ↓
    Block’s toHtml() method → is called automatically
  4. ↓
    toHtml() internally calls _toHtml(), which includes the .phtml file
  5. ↓
    Inside the .phtml file, the variable $block refers to YOUR Block instance
  6. ↓
    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/inventory on 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 NameWhen DispatchedAvailable Data
catalog_product_save_afterAfter product savedproduct
checkout_cart_add_product_completeAfter product added to cartproduct
sales_order_place_afterAfter order placedorder
customer_register_successAfter customer registrationcustomer
controller_action_predispatchBefore any controller actioncontroller_action
cms_page_renderBefore rendering CMS pagepage

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:

  • final methods or final classes
  • Static methods
  • Constructors (__construct)
  • Non-public methods

Three types of plugin methods:

TypeMethod NamingWhen it RunsTypical Use
BeforebeforeMethodName()Before the original methodValidate or modify arguments
AfterafterMethodName()After the original methodModify the return value
AroundaroundMethodName()Wraps the original methodAdd 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:

TaskBest Solution
Modify arguments before a method callBefore plugin
Modify return value after a methodAfter plugin
Add behaviour both before and afterAround plugin
Add a completely new method to a classPreference
Change constructor signaturePreference or virtual type
Fix a bug not fixable by pluginPreference (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:

ElementPurpose
<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:

TablePurpose
catalog_product_entityBase product table
catalog_product_entity_varcharText values for product attributes
catalog_product_entity_intInteger values
catalog_product_entity_decimalDecimal values
catalog_product_entity_datetimeDate values
eav_attributeAttribute 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:

  1. Shipping Address
  2. Shipping Method
  3. Payment Method
  4. 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 placed
  • sales_order_save_after — after order is saved
  • sales_order_invoice_save_after — after invoice is saved
  • sales_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 TypeDescription
configSystem configuration, module settings
layoutLayout XML compiled into structures
block_htmlRendered HTML of individual blocks
collectionsDatabase query results
full_pageComplete HTML of pages (most important)
translateTranslated 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:

IndexerAffects
catalog_product_pricePrice filtering, product list
catalog_product_attributeLayered navigation, product page
catalogsearch_fulltextProduct search
cataloginventory_stockStock availability
catalog_category_productCategory 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:

MethodUse 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

  1. Get reCAPTCHA keys from https://www.google.com/recaptcha
  2. Configure: Stores → Configuration → Security → Google reCAPTCHA
  3. Enter Site Key and Secret Key
  4. 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

RequirementHow Magento HelpsDeveloper Responsibility
Secure networksSupports TLS 1.2+Configure web server for HTTPS only
Protect cardholder dataEncryption (EncryptorInterface)Never log card numbers; use tokenisation
Strong access controlACL, 2FA, reCAPTCHAEnforce 2FA for all admin users
Monitor and testLogging (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:

LevelDescriptionExample
WebsiteTop-level; own customers, orders, checkoutUS Website, EU Website
StoreShares category structure with parent websiteEnglish Store, French Store
Store ViewDefines language, currency, themeEnglish (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:

AspectMonolithMicroservices
DeploymentSingleIndependent
ScalabilityScale everythingScale individually
ComplexityLower (code)Higher (infrastructure)
Time to MarketSlowerFaster per service
Best ForModerate complexityLarge organisations, multiple teams

Headless vs Traditional:

AspectTraditionalHeadless
FrontendMagento themesReact, Vue, Next.js
PerformanceGoodExcellent
OmnichannelLimitedNative
Development SpeedSlowerFaster

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:

ComponentRedundancy Strategy
Web ServersMultiple instances behind load balancer
DatabaseMaster-slave or multi-master replication
CacheRedis cluster
SearchElasticsearch/OpenSearch cluster
Load BalancerActive-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:

AlgorithmDescriptionUse Case
Round-RobinSequential distributionSimple, uniform traffic
Least ConnectionsSends to server with fewest active connectionsVariable request durations
IP HashConsistent hashing based on client IPSticky sessions
Weighted Round-RobinServers have weights based on capacityUnequal server capacity

20.5 Disaster Recovery

DR strategies:

StrategyRTORPOCostUse Case
Backup and RestoreHours-DaysHoursLowSimple DR
Pilot LightMinutes-HoursNear real-timeMediumCost-efficient HA
Warm StandbyMinutesNear real-timeHighCritical systems
Active-ActiveSecondsZeroVery HighGlobal performance

DR Runbook:

  1. Incident Detection
  • Alert fires, confirm site is down from multiple locations
  • Notify the DR team
  1. Assessment
  • Component failure or site-wide?
  • Expected recovery time? Data lost?
  1. Initiate Failover
  • Update DNS to DR site IP
  • Wait for DNS propagation (TTL: 300 seconds)
  • Verify site accessible at DR site
  1. Stabilise
  • Monitor performance, process queued orders
  • Notify customers if needed
  1. Recovery
  • Identify root cause, fix issue in primary site
  • Plan failback during low-traffic period
  1. Post-Incident Review
  • What went well? Wrong? How to improve?

20.6 Technical Leadership

Key responsibilities:

ResponsibilityDescription
Architecture DesignDesign enterprise-scale systems, create ADRs
Technology StrategyDefine technical direction and standards
Technical Decision-MakingMake trade-off decisions (build vs buy)
MentoringGuide junior developers, conduct code reviews
Stakeholder ManagementTranslate technical concepts for business leaders
Risk ManagementIdentify 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:

RuleExample
Strict typesdeclare(strict_types=1); at top of every PHP file
Return type declarationspublic function getName(): string
Property visibilityAlways private or protected, never var
Constructor injectionNo ObjectManager::getInstance()
Escaping$escaper->escapeHtml() in templates
Line lengthMaximum 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.log for 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:

CertificationTarget RoleExam Code
Adobe Certified Expert — Adobe Commerce DeveloperDeveloperAD0-E703
Adobe Certified Expert — Adobe Commerce Front-End DeveloperFrontend DeveloperAD0-E708
Adobe Certified Master — Adobe Commerce ArchitectSolution ArchitectAD0-E709

Developer certification exam topics:

TopicWeight
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 & Performance5%

Architect certification exam topics:

TopicWeight
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:

ItemDescription
Custom ModulesShowcase module development with good documentation
ThemeCustom theme with design decisions explained
IntegrationsERP, CRM, payment gateway integrations
Performance Case StudiesBefore/after metrics from optimisation projects
Open Source ContributionsPull 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.

Scroll to Top