genaker/laragento
Laragento bridges Magento and Laravel, letting you query Magento products, orders, and customers through familiar Eloquent-style models. Connect to your store, build integrations, sync catalogs, and extend Magento with Laravel's tooling and conventions—no boilerplate.
1. Install and publish config
composer require genaker/laragento
php artisan vendor:publish --provider="Genaker\Laragento\LaragentoServiceProvider"
The published config/laragento.php holds the Magento DB credentials. Treat that file as the source of truth for the env key names — the package was last released in Feb 2022, so verify the keys against the file rather than against a blog post.
2. Point it at a read replica, and create a SELECT-only MySQL user
This is the single most important setup step. Do not hand the package your Magento app user.
CREATE USER 'laravel_ro'@'%' IDENTIFIED BY '...';
GRANT SELECT, SHOW VIEW ON magento.* TO 'laravel_ro'@'%';
Magento stores its table prefix (db_prefix in app/etc/env.php) separately from the connection — set it on the connection config or every query will hit sales_order when the table is actually mage_sales_order.
3. Prefer an explicit Laravel connection over the package's globals
Even if the provider registers its own connection, defining a named one keeps you in control and survives package abandonment:
// config/database.php
'magento' => [
'driver' => 'mysql',
'host' => env('MAGENTO_DB_HOST'),
'database' => env('MAGENTO_DB_DATABASE'),
'username' => env('MAGENTO_DB_USERNAME'),
'password' => env('MAGENTO_DB_PASSWORD'),
'prefix' => env('MAGENTO_DB_PREFIX', ''),
'charset' => 'utf8',
'collation' => 'utf8_general_ci',
'strict' => false, // Magento's own queries assume permissive mode
],
4. Where to look first
src/Models/ — the shipped Eloquent models (sales orders, products, customers). Read them before writing your own; they already set $primaryKey = 'entity_id', $connection, and often $timestamps = false.config/laragento.php — connection shape.laragento is auto-registered, and whether the models hardcode that name.5. First use case: an order-lookup / reporting screen
The highest-value, lowest-risk entry point is read-only reporting — something Magento's admin is bad at and your Laravel app is good at.
use Illuminate\Support\Facades\DB;
$orders = DB::connection('magento')
->table('sales_order_grid')
->whereBetween('created_at', [$from, $to])
->where('store_id', $storeId)
->orderByDesc('entity_id')
->paginate(50);
sales_order_grid and customer_grid_flat are pre-joined, pre-computed, and indexed — they are almost always the right table for list views.
Model per Magento aggregate, never per table. Magento's schema is a join graph; a model is a read-side aggregate. Keep them in App\Magento\Models with an Magento base class that centralises connection and key conventions:
abstract class MagentoModel extends Model
{
protected $connection = 'magento';
protected $primaryKey = 'entity_id';
public $timestamps = false;
public $guarded = ['*']; // read-only by accident, too
}
class SalesOrder extends MagentoModel
{
protected $table = 'sales_order';
public function items()
{
return $this->hasMany(SalesOrderItem::class, 'order_id', 'entity_id');
}
public function addresses()
{
return $this->hasMany(SalesOrderAddress::class, 'parent_id', 'entity_id');
}
}
Two-tier reads: grid table for lists, entity tables for detail. sales_order_grid for the index page; sales_order + sales_order_item + sales_order_address for the detail page. Same for customers (customer_grid_flat vs customer_entity + customer_address_entity).
Resolve EAV once, cache forever. Never join eav_attribute in a hot path. Resolve attribute IDs at boot into the Laravel cache and reuse:
$nameAttrId = Cache::rememberForever('magento.attr.product.name', fn () =>
DB::connection('magento')->table('eav_attribute')
->where('entity_type_id', 4) // catalog_product
->where('attribute_code', 'name')
->value('attribute_id')
);
Then join only the one value table you need (catalog_product_entity_varchar for name, _decimal for price, _int for status/visibility), with store_id = 0 for the default-scope value and a COALESCE against the store-specific row.
If flat catalog is enabled, use it. catalog_product_flat_{storeId} is a single denormalised table with all attributes — it turns a five-join EAV query into a WHERE. Check core_config_data (catalog/frontend/flat_catalog_product) before relying on it.
Chunk everything that can grow. sales_order on a busy store is millions of rows. chunkById(1000), lazy(), cursor() — never ->get() on an unbounded order query. For exports, cursor() + streamed response.
Use query objects / repositories, not controllers. One class per report keeps the Magento SQL in one place when (not if) you need to swap tables:
class OrderRevenueReport
{
public function __construct(private Connection $db) {}
public function daily(string $from, string $to): Collection
{
return $this->db->table('sales_order')
->selectRaw('DATE(created_at) d, store_id, SUM(base_grand_total) total, COUNT(*) orders')
->where('state', 'complete')
->whereBetween('created_at', [$from, $to])
->groupBy('d', 'store_id')
->get();
}
}
Cache the small, hot dimension tables. store, store_website, store_group, eav_attribute_set — a few hundred rows at most. Load once into a repository, resolve store_id → code in PHP.
ETL into your own schema for anything dashboard-heavy. Don't run eight aggregations against the live Magento box on every page load. An Artisan command that upserts into a reporting_orders table on a schedule is the pattern that survives production.
Integration tips
php artisan tinker with DB::connection('magento')->table(...) is the fastest way to explore the schema — faster than reading db_schema.xml.DB::connection('magento')->disableQueryLog()) in any loop; the debug bar will OOM on a 500k-row chunk job.select() — Magento tables have 80+ columns each and serialising sales_order raw leaks internal fields.License: GPL-3.0. This is the first thing to check with legal. GPL is copyleft; linking it into a proprietary Laravel codebase is a distribution question most companies won't want to answer. Many teams use it strictly as a dev/reference aid and reimplement the (trivial) connection + base-model plumbing themselves — which is genuinely about 40 lines.
It is a read tool. Do not write. Eloquent save() against Magento tables silently corrupts the store: indexers don't run, sequence_* tables aren't advanced (auto-increment is emulated), EAV index tables go stale, and caches aren't invalidated. Enforce it at the DB grant level (SELECT only) — that's the only guardrail that actually holds.
Stale package. Last release Feb 2022, so it targets Laravel 8/9-era APIs. Pin it and be prepared to fork. The models are thin enough that replacing them with your own subclasses of Illuminate\Database\Eloquent\Model is a low-effort hedge.
Primary keys are entity_id, not id. find(), route-model binding, and whereKey() all break if you forget $primaryKey. Same for item_id, value_id, attribute_id on the sub-tables.
Timestamps are UTC, and Eloquent's conventions don't hold. Magento writes created_at/updated_at in UTC; display conversion is a presentation concern. Set $timestamps = false and $dateFormat explicitly. Many tables have no updated_at at all.
Encrypted columns are unreadable. Magento encrypts at rest using the crypt/key in app/etc/env.php (AES-256-CBC, sometimes with a key ring in 2.4). Reading customer_entity.rp_token, encrypted config in core_config_data, or certain payment fields gives you ciphertext. You cannot decrypt without that key — and you probably shouldn't pull it into the Laravel app.
Password hashes are not Laravel hashes. customer_entity.password_hash is hash:salt:version with Argon2id in 2.4. Hash::check() will fail. If you need auth against Magento customers, port Magento's Encryptor verification.
Serialized PHP everywhere. sales_order_item.product_options, additional_data, quote_item blobs are serialize() output, not JSON. Use unserialize($v, ['allowed_classes' => false]) — and be aware older data may be PHP-serialized while newer is JSON.
Money columns are ambiguous. grand_total is in order currency, base_grand_total in base currency. For cross-store reporting always aggregate on base_*, or your totals are nonsense.
Grid tables can be stale. sales_order_grid is populated by an indexer. If Magento's cron is broken, your "live" dashboard is quietly wrong. For anything that must be exact, hit sales_order directly.
Store scoping. store_id = 0 is admin/default scope. A global-scope attribute value lives at store_id = 0 and is overridden per store. Missing the COALESCE(default, store) pattern is the classic bug that makes product names disappear on non-default store views.
Commerce vs Open Source. Magento Commerce with content staging adds row_id/link_field to product entities, so the EAV join key differs (row_id instead of entity_id). Any EAV query you write is version- and edition-specific — wrap it and test against the actual instance.
Config caching bites. php artisan config:cache freezes the Magento credentials; changing .env without re-caching silently keeps the old connection. Also watch ONLY_FULL_GROUP_BY — Magento's own my.cnf typically relaxes it, your Laravel connection should match ('strict' => false).
Extension points worth knowing: the connection name (so you can point at a replica per environment), the base model (so you can add global scopes for store_id filtering), and the config file (so you can swap credentials via a secrets manager). If the package doesn't expose these cleanly, that's your first fork.
How can I help you explore Laravel packages today?