Xdebug and Logs
Attach Xdebug on port 9003, turn on the printed error page, and read var/log and var/report instead of guessing.
xdebug.mode=debug xdebug.client_host=host.docker.internal xdebug.client_port=9003Magento generates code, intercepts method calls and merges XML from every module, so reading an error is usually cheaper than reasoning about one. Two things remove the guesswork: a debugger that stops inside a real request, and knowing which file the error was already written to.
Xdebug
Your IDE listens and Xdebug connects out to it. The port is xdebug.client_port, 9003 since Xdebug 3; Xdebug 2 used 9000, which is why old guides mislead. From a container, xdebug.client_host must point back at the host — host.docker.internal on Docker Desktop. Set xdebug.start_with_request=trigger so only requests carrying XDEBUG_TRIGGER are debugged, and the rest of the site stays fast.
Logs and reports
Three files in var/log: system.log for warnings and notices, exception.log for exceptions and traces, debug.log for debug-level messages. In Magento 2.4, debug.log is written in developer and default mode and is off in production mode, unless dev/debug/debug_logging in app/etc/env.php says otherwise. An uncaught exception outside developer mode also writes var/report/<id> and shows the visitor only that id; the file holds the message and the trace.
The printed error page
Copy pub/errors/local.xml.sample to pub/errors/local.xml. With <action>print</action> the exception prints instead of the generic page. No cache command is needed, because pub/errors is read on the request that failed.
What trips people up
Loading Xdebug in debug mode for every process makes setup:di:compile and setup:static-content:deploy several times slower.
Example
; docker/php/conf.d/xdebug.ini - mounted at /usr/local/etc/php/conf.d/xdebug.ini
zend_extension=xdebug
xdebug.mode=debug
xdebug.start_with_request=trigger
xdebug.client_host=host.docker.internal
xdebug.client_port=9003
xdebug.idekey=PHPSTORM
xdebug.log=/tmp/xdebug.log
; start_with_request=trigger means a request is debugged only when it carries
; XDEBUG_TRIGGER as a GET or POST parameter, a cookie, or an environment variable.
;
; Longhand: the settings above, loaded for every process in this container.
; Shorthand for a single command, same settings, nothing else affected:
;
; php -dxdebug.mode=debug -dxdebug.start_with_request=yes \
; bin/magento setup:upgrade
;
; Both set the same two options. The -d form is the one to use for CLI work,
; because it leaves di:compile and static-content:deploy running at full speed.When to use it
- Stop inside a controller to see the request Magento actually received.
- Turn a 500 page with a report id into the exception message and trace behind it.
- Step through a bin/magento command without slowing down every other PHP process.
- Find out which module logged the warning a client sent you a screenshot of.
More examples
Reading what Magento already logged
Start here before attaching a debugger, because a 500 has usually already written its own explanation. The report id is the file name, so there is nothing to search for. Reach for setup:config:set when you need debug.log on a production-mode server for a short investigation, and turn it off afterwards; it writes a deployment configuration value, not a store setting.
# all three log files at once, from the Magento root
tail -f var/log/system.log var/log/exception.log var/log/debug.log
# the error page prints a report id; the report is the file with that name
ls -t var/report | head -5
cat var/report/44ffb1087a44e61b018b3cdee72284d017f22e52755c24e5c85cbac1647ae7a7
# turn debug logging on explicitly - this writes dev/debug/debug_logging
# into app/etc/env.php
bin/magento setup:config:set --enable-debug-logging=1The developer error page
With this file in place, a failing request prints the exception and trace rather than the generic page with a report id. On the storefront that is only acceptable on a machine nobody else can reach, so keep local.xml out of the deployed artifact for anything public. dir_nesting_level above 0 spreads reports into subdirectories named after the first characters of the id, which you will want on a site generating thousands of them.
<?xml version="1.0"?>
<!-- pub/errors/local.xml - copied from pub/errors/local.xml.sample -->
<config>
<skin>default</skin>
<report>
<!-- print shows the exception on screen; email sends it instead -->
<action>print</action>
<subject>Store Debug Information</subject>
<email_address></email_address>
<!-- leave keeps the trace in var/report; delete removes it -->
<trash>leave</trash>
<!-- 0 writes reports straight into var/report -->
<dir_nesting_level>0</dir_nesting_level>
</report>
</config>Logging from your own code
Magento's logger is PSR-3, so the type to ask for is Psr\Log\LoggerInterface and the level you call decides the file. The second argument is a context array, which keeps the message greppable instead of interpolating values into it. The promoted and unpromoted constructors shown here are interchangeable; how Magento supplies that argument is the subject of the dependency injection stage.
<?php
// app/code/Vendor/Diagnostics/Service/CheckoutAudit.php
declare(strict_types=1);
namespace Vendor\Diagnostics\Service;
use Psr\Log\LoggerInterface;
class CheckoutAudit
{
private LoggerInterface $logger;
public function __construct(LoggerInterface $logger)
{
$this->logger = $logger;
}
// PHP 8.4 constructor property promotion is the same constructor signature,
// so Magento resolves the dependency identically:
//
// public function __construct(private LoggerInterface $logger)
// {
// }
public function recordAttempt(string $reference, string $method): void
{
$this->logger->debug('checkout attempt', ['reference' => $reference, 'method' => $method]);
$this->logger->error('checkout declined', ['reference' => $reference]);
}
}
// debug() lands in var/log/debug.log, error() in var/log/system.log.
Discussion