Running Eloquent queries on a production Laravel app, safely

October 2026 · 6 min read

Sooner or later you need to look at real data: "how many orders are stuck in pending?", "does this user actually have the role?", "what does that relation return?". The staging database never has the interesting rows. Here is how to answer those questions on a live app over SSH without changing anything, and how to make it a habit you can repeat instead of a thing you dread.

1. Tinker is already there

Every Laravel app ships with php artisan tinker. Over SSH it gives you the whole application: models, config, the database connection the app really uses.

ssh forge@shop.example.com
cd /home/forge/shop.example.com/current
php artisan tinker
>>> Order::where('status', 'pending')->count()
=> 37

For one question that is enough. For anything longer, the interactive shell gets in the way: no multi-line editing, output that scrolls off, and it is easy to mistype something destructive.

2. One-liners and snippet files with --execute

Tinker can run code non-interactively. That turns a question into a command you can repeat, put in a script, or paste into a ticket:

php artisan tinker --execute="dump(Order::where('status', 'pending')->count());"

Longer snippets live better in a file. Keep them in a folder on your Mac and send them over SSH; nothing stays on the server:

# snippets/pending-orders.php
$pending = Order::where('status', 'pending')
    ->where('created_at', '<', now()->subDay())
    ->with('user')
    ->get();

dump($pending->count());
dump($pending->map(fn ($o) => [$o->number, $o->user->email, $o->created_at->diffForHumans()]));
ssh forge@shop.example.com "cd /home/forge/shop.example.com/current && php artisan tinker --execute=\"$(cat snippets/pending-orders.php)\""

A small shell function makes that one word:

tink() { ssh "$1" "cd $2 && php artisan tinker --execute=\"$(cat "$3")\""; }
tink forge@shop.example.com /home/forge/shop.example.com/current snippets/pending-orders.php

3. Read-only by default

The mistake is never the query you meant to run; it is the ->update() you ran to "test something". Three habits cover almost all of it.

Wrap the snippet in a transaction you always roll back. Everything runs, you see every result, and nothing is committed:

DB::beginTransaction();
try {
    // your snippet
    $user = User::where('email', 'a@example.com')->first();
    $user->update(['role' => 'admin']);   // happens inside the transaction only
    dump($user->fresh()->role);
} finally {
    DB::rollBack();
}

Look at the SQL before running it on a big table. ->toSql() and ->getBindings() cost nothing; ->count() on an unindexed column can.

dump(Order::where('status', 'pending')->toSql());

Log every query the snippet runs. Then you know what Eloquent actually did, including the N+1 you did not expect:

DB::listen(fn ($q) => dump("{$q->time} ms  {$q->sql}"));
If the app has a read replica, point the snippet at it: DB::connection('mysql_read'). Then even a slip cannot write.

4. Things that bite on a server

5. Make it a workflow

The pattern that sticks: snippets as files, one command to run a file on a chosen server, output you can read. Nothing is installed on the server and the project is untouched; the code arrives with the command and leaves with it.

snippets/
  orders-pending.php
  user-roles.php
  cache-keys.php

Add the tink function from above, a transaction wrapper you paste at the top, and you have most of a production console with no new infrastructure.

Checklist

If you want the same workflow with a window instead of a terminal: Heron is a PHP scratchpad for Mac that runs a snippet inside your project, locally or on a server over SSH, and shows each value as a card, with the queries it ran and the project's logs next to it. It is what this post grew out of. Everything above works without it.