Leveling Up With Tinker

/ Content

If you use Laravel, you use Tinker. You use it in both development and production environments to do database queries and updates safely, or to launch jobs, or to debug and step through code. If you are like me, Tinker is pulled up in the console almost all day long while I am working. As such, I have spent a lot of time reviewing the docs of PsySH (Tinker's underlying REPL), and even the codebase. Everything PsySH can do, Tinker can do. Laravel just pre-loads your app so your models, services, and helpers are ready to go. There are lots of things you can do to level up your skills. This is going to be a multipart series covering the basics (things you probably already know), commands and config, and customization.

Getting familiar with the shell

Tab completion

Before we get into the commands, Tinker supports tab completion. Start typing a class name, method, or variable and hit Tab. It autocompletes or shows you the available options. This works for your app's classes (User, Order), PHP built-ins, and variables you've already defined in the session. Once you start using it, you'll wonder how you ever typed out full class names manually.

The pager: less

When Tinker returns more data than fits on your screen, it pipes the output into a pager, less by default. You can review the full less docs elsewhere online, but these are the keys I use constantly:

Key Action
Space Page down
b Page up
/pattern Search forward
G Jump to end
g Jump to start
q Quit the pager

Most of the time, though, I don't need pages of output. I just need to assign the data to a variable so I can interact with it. Which brings up the magic $_ variable.

The hidden $_ variable

Have you ever done this:

> User::find(1)->email;
= "admin@admin.com"

> $e = User::find(1)->email;
= "admin@admin.com"

You are looking up something, but you forgot to set it as a variable. So, if you are shell-savvy, you:

  1. hit the up arrow key
  2. go home (Cmd+Left or Ctrl+A or the Home key)
  3. you type $e = in front of the original command
  4. you hit enter

That is 4 steps, and several keys to set a variable... which honestly isn't much, but there is a faster way: $_. The shell automatically stores the result of the last command as $_, mainly to help in situations like this. That means, you can do this instead:

> User::find(1)->email;
= "admin@admin.com"

> $e = $_
= "admin@admin.com"

Remember that $_ always becomes the result of the latest command, so this wouldn't work:

> User::find(1);
> $email = $_->email
> $id = $_->id
  WARNING  Attempt to read property "id" on string.
= null

The first $_ is the result of User::find(1);. The second $_ is the email string and no longer the User object.

Getting familiar with the commands

Rather than giving a run down of EVERY command, I am cherry picking the commands I use most often and are the most helpful.

The help command

If you haven't already, type help in Tinker. It gives you a list of every command provided and a useful description of what each command does. You will likely notice a few "artisan" commands mixed in (clear-compiled, down, env, optimize, up, migrate, and migrate:install). The artisan commands are literally the exact commands you would get if you ran php artisan clear-compiled or php artisan migrate. There is no difference whether you run them via Tinker or in your terminal. We aren't going to go over those since they don't enhance our Tinker experience.

The command descriptions for each command do a great job at explaining what they do, but let's dive into a couple and show how to use them properly.

The history command

Can't remember the exact snippet you ran 30 minutes ago? The one you spent 10 minutes tweaking and working through errors before you finally got it right? history is your friend:

> history
  1: User::find(1)->email;
  2: $user = User::where('email', 'like', '%@example%')->first();
  3: $user->orders()->with('items')->get();
  4: $user->orders()->where('status', 'completed')->sum('total');

Can't remember the specific text, just a classname or variable? Throw in a --grep:

> history --grep orders
  3: $user->orders()->with('items')->get();
  4: $user->orders()->where('status', 'completed')->sum('total');

WTF is wtf?

Uh-oh. Your code just threw an error while calling a service, which made a query, which did some updates, which ran some calculations, along with some model events and listeners... Tinker just spit out a generic error and that was it. No line numbers, no stack trace. WTF just happened?

Your instinct might be to wrap everything in a try/catch:

> try { $order->process(); } catch (Exception $e) { dump($e); }
# 🤮 Wall of text, nested exception objects, hundreds of lines...

Instead, just type wtf:

> $order->process();
  ERROR  SQLSTATE[23000]: Integrity constraint violation...

> wtf
# Clean stack trace:
#   1 app/Services/OrderService.php:42
#   2 app/Models/Order.php:128
#   3 Illuminate/Database/Eloquent/Model.php:2198

> wtf -a
# Full stack trace with all frames, still formatted cleanly

You get the exact file and line number without any of the noise.

Listing class properties and methods with ls -la

In your terminal, you type ls to get a directory's contents. Same idea here, but for PHP classes. You get methods, properties, and constants.

> ls -la User
# Class Methods:
#   public static factory($count, $state)
#   public static query()
#   public orders(): HasMany
#   public getFullNameAttribute(): string
#   ...
#
# Class Properties:
#   public string $email
#   protected string $password
#   ...
#
# Class Constants:
#   ADMIN_ROLE = "admin"

Where this really shines is when you're working with unfamiliar objects. Say you're using Cashier and have a Stripe Subscription object. Does it have save() or update()? Instead of pulling up the docs:

> $stripeSubscription = $user->subscription('default')->asStripeSubscription();

> ls -la $stripeSubscription
# ... scan the method list ...
#   public save()       ✅
#   public update()     ✅

Sudo-ing your code with sudo

What if the method you need to call is private or protected? Without sudo, your options kind of suck:

> $service->getClient();
  ERROR  Call to protected method App\Services\PaymentService::getClient()

# Option 1: Edit /vendor source code, make it public, restart Tinker... 😩
# Option 2: Reflection wizardry
> $ref = new ReflectionMethod($service, 'getClient');
> $ref->setAccessible(true);
> $client = $ref->invoke($service);
# It works, but that was 3 lines for a simple method call.

With sudo, you just skip all of that:

> sudo $service->getClient();
= GuzzleHttp\Client { ... }

> sudo $service->getClient()->get('/api/status');
= GuzzleHttp\Psr7\Response { ... }

This has been crazy helpful when working with legacy code where underlying Guzzle clients with auth tokens were private or protected and I wanted to make calls not implemented by the object. No more modifying vendor code or adding temporary getters just to debug something. Just don't go using it to routinely bypass intentional encapsulation in your own code. It's a debugging escape hatch, not a design pattern.

Last, but not least, edit

For when you have a whole function's worth of code you want to debug, pasting it into Tinker line-by-line is painful. Line breaks get mangled, you lose your indentation, and one typo means starting over.

Instead, type edit and Tinker opens your terminal editor (vim, nano, etc.) with a scratch file. Write your code with proper formatting, save and quit, and Tinker executes the entire thing:

> edit
# Opens your editor with a blank file. Write something like:

$users = User::where('created_at', '>', now()->subDays(30))->get();

$users->each(function ($user) {
    $orderCount = $user->orders()->count();
    $total = $user->orders()->sum('total');

    echo "{$user->name}: {$orderCount} orders, \${$total} total\n";
});

# Save and quit — Tinker runs it all at once.

You can also edit a previous command by passing the history number: edit 3 opens command #3 from your history, ready to tweak and re-run.

What's Next

That covers the basics. The stuff that makes Tinker feel less like a bare REPL and more like a proper tool. In the next part of this series, we are going to get into configuration. Tinker (via PsySH) has a config file that most people don't know exists, and it unlocks things like custom commands, default includes, error verbosity, and history settings. If you've ever wished Tinker "just did" something automatically when it starts up, that's where it happens.