I've worked in Magento 2 full-time for 5.5 years, and in Laravel on and off since 2015. Every time I put the two side by side, the same thing happens: the names change, the engineering doesn't. The console is the clearest example.
Same component underneath
In Magento 2.4.9, Magento\Framework\Console\Cli, the class behind bin/magento, extends Symfony's Console Application. In Laravel 13, so does Illuminate\Console\Application, the class behind php artisan. Every command in both frameworks extends Symfony's Command.
So arguments, options, input, output and return codes work the same way. Only registration differs. Magento collects commands from di.xml. Laravel picks up the classes in app/Console/Commands.
The trap they share
When bin/magento starts, it builds every command registered in CommandListInterface, with all of its constructor dependencies, even if all you asked for was cache:flush. One heavy constructor slows down every command in the shop.
The classic fix is a generated Proxy, wired in di.xml. The heavy service is created only when the command actually runs. The core does this itself in the image-resize command, in 2.4.7 as in 2.4.9.
<type name="Magento\Framework\Console\CommandListInterface"> <arguments> <argument name="commands" xsi:type="array"> <item name="acme_export" xsi:type="object">Acme\Export\Console\ExportOrders</item> </argument> </arguments> </type> <!-- built on every bin/magento run: inject the heavy service lazily --> <type name="Acme\Export\Console\ExportOrders"> <arguments> <argument name="exporter" xsi:type="object">Acme\Export\Model\OrderExporter\Proxy</argument> </arguments> </type> class ExportOrders extends Command { public function __construct(private OrderExporterInterface $exporter) { parent::__construct('acme:export-orders'); } protected function execute(InputInterface $input, OutputInterface $output): int { $output->writeln($this->exporter->export() . ' orders exported'); return Cli::RETURN_SUCCESS; } } Since 2.4.8 there's a cleaner way. Magento\Framework\Console\CommandLoader implements Symfony's lazy CommandLoaderInterface. Register the command there by name, and Magento builds it only when you call it. No Proxy needed. 2.4.7 doesn't have it.
<!-- 2.4.8+: registered by name, built only when called --> <type name="Magento\Framework\Console\CommandLoader"> <arguments> <argument name="commands" xsi:type="array"> <item name="acme_export" xsi:type="array"> <item name="name" xsi:type="string">acme:export-orders</item> <item name="class" xsi:type="string">Acme\Export\Console\ExportOrders</item> </item> </argument> </arguments> </type> Laravel has the same split. A command without the #[AsCommand] attribute is resolved from the container when Artisan starts. With the attribute, it's registered by name and built only when you call it. Or you ask for the service in handle(), and the container resolves it on run.
One Laravel 13 detail: make:command now writes a #[Signature] attribute instead of the $signature property that 11 and 12 use. That attribute alone doesn't make the command lazy. Artisan only checks for #[AsCommand] in all three versions, so I keep both.
#[Signature('acme:export-orders')] #[AsCommand(name: 'acme:export-orders')] class ExportOrders extends Command { public function handle(OrderExporter $exporter): int { $this->info($exporter->export() . ' orders exported'); return self::SUCCESS; } } The rule
Keep command constructors cheap, or make them lazy. It holds in both frameworks because it's the same component.
I ran every snippet here on Magento 2.4.9 and Laravel 13.40.
This is the first of 15 short comparisons between Magento 2 and Laravel, one idea at a time, with code. Next: why both frameworks fire save events before the commit, and what that does to a queue.
Part 1 of 15 of the Magento 2 vs Laravel series. Originally published on LinkedIn.
