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.
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.
--executeTinker 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
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}"));
DB::connection('mysql_read'). Then even a slip cannot write..env. Queued jobs, mails and events fire for real. Use Mail::fake() / Event::fake() at the top of a snippet when you only want to read.User::all() on a real table is how you learn what memory_limit is. Prefer ->limit(), ->chunk() or ->cursor()..php files is a better audit trail than shell history.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.
toSql() first on large tables; limit, chunk, cursor.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.