psy/psysh
PsySH is a runtime developer console, interactive debugger, and REPL for PHP. Inspect variables, evaluate expressions, and debug live code from your terminal, with configurable themes, history, and integrations like Laravel Tinker.
In a Laravel app you almost never install PsySH directly. Laravel ships laravel/tinker (which depends on psy/psysh) in composer.json by default. The day-to-day entry point is:
php artisan tinker
For a non-Laravel project, or to run the raw shell outside a framework:
composer require --dev psy/psysh
vendor/bin/psysh
Where to look first:
config/tinker.php — the Laravel-facing surface for PsySH options (commands, alias, dont_alias). Most behavior you'll want to change lives here rather than in a .psysh.php.~/.config/psysh/config.php (or $PSYSH_CONFIG) — global, per-user config: theme, pager, history, readline, semicolonsSuppressReturn, clipboardCommand, trustProject.help, help <command>, ls, doc <thing>, and psy\info() (or launch with --info) to see what readline/autocomplete engine is actually active.First real use case — poke at a model without writing a throwaway route:
$user = App\Models\User::with('posts')->first();
$user-> // press Tab: real property/method list, not a guess
$user->posts->first()->author-> // chains through return types
ls $user // grouped listing, magic members in magenta
doc $user->posts // PHP manual / docblock-aware help
Note the first-run trust prompt (v0.12.19+). PsySH refuses to load project-local config, local binaries, or Composer autoloads until you approve the directory. Approve it once interactively; the decision is persisted in trusted_projects.json.
Replacing dd() and var_dump() during development. The shell is a REPL, so the useful pattern is iterative narrowing rather than one-shot dumping:
$order = App\Models\Order::find(42);
$order->items->sum('price'); // compute in place
$_->toArray(); // $_ is the last result
$__->getMessage(); // $__ is the last exception, if any
wtf // show that exception with context
trace // full backtrace of last error
whereami // which file/line am I in (when dropped into a breakpoint)
Non-interactive execution for scripts and CI. This is the pattern most teams underuse. artisan tinker accepts piped stdin and --execute:
php artisan tinker --execute="echo App\Models\User::count();"
# or pipe a whole script
cat scripts/backfill.php | php artisan tinker
Wrap that in a Makefile target or a scheduled Artisan::call-adjacent task and you get throwaway data migrations without a committed command class. Always pass --no-interaction in automated contexts so the trust prompt and readline setup don't block.
Programmatic shell inside your own Artisan command or tests. Psy\Shell is a public API, not just a binary:
use Psy\Shell;
use Psy\Configuration;
$config = new Configuration();
$config->setUpdateCheck(Configuration::UPDATE_CHECK_NEVER);
$config->setStartupMessage('');
$shell = new Shell($config);
$shell->setScopeVariables(['app' => $this->laravel, 'user' => $user]);
$shell->execute('$user->load("posts"); echo $user->posts->count();');
This is how downstream tools (Drush, MediaWiki maintenance scripts, composer-repl) embed PsySH, and it's a reasonable way to give yourself an "open a REPL with this object already in scope" debugging affordance. Use setBoundObject() / setBoundClass() when you want $this and self:: to resolve inside your domain object.
Custom casters for pretty output. Register a caster so your value objects and DTOs print usefully instead of as anonymous blobs:
// ~/.config/psysh/config.php
return [
'casters' => [
App\ValueObjects\Money::class => function (App\ValueObjects\Money $m, array $a, Psy\Shell $s) {
return $s->format($m->amount() . ' ' . $m->currency());
},
],
];
Custom commands for team-wide workflows. Implement Psy\Command\Command and register it in config/tinker.php under 'commands' => [...], or drop it in app/PsySH/ and list it. Commands can define their own argument completion via CommandArgumentCompletionAware (v0.12.22+), which makes a seed --model <Tab> style command genuinely pleasant.
Good candidates: seed (call a specific seeder without db:seed), sql (dump the query builder SQL under the current scope), login (issue a Sanctum token for a user), mail-preview (render a Mailable to $_).
Import discipline inside a session. use App\Models\User; persists for the rest of the session, and as of v0.12.22 aliases are tracked by import type (class/function/constant), so use function resolution works correctly. Prefer explicit imports over fully-qualified names — tab completion on a fully-qualified prefix is noisier.
Runtime config without restarting. As of v0.12.22, config changes settings live:
config pager=false
config theme=light
config useSyntaxHighlighting=false
And copy $_ / copy $order->items pushes to the system clipboard (or OSC 52 over SSH via useOsc52Clipboard), which is the fastest way to get a big result out of the terminal.
Time something before committing it to code:
timeit -n 100 App\Models\Order::with('items')->find(1)
Writes are real writes. There is no implicit transaction or rollback in Tinker. $user->delete() deletes. If you're exploring destructive paths, open with DB::beginTransaction() and close with DB::rollBack(), or use a dedicated scratch database. This is the single most common way people damage a development dataset.
Trust / Restricted Mode will bite you in containers and CI. Since 0.12.19, a .psysh.php in the CWD is not loaded unless the directory is trusted. Symptoms: your project config silently isn't applied, startup message missing, custom commands absent. Controls:
'trustProject' => 'prompt' | 'always' | 'never' in config--trust-project / --no-trust-projectPSYSH_TRUST_PROJECT env var — set this in Docker images and CI rather than fighting the prompt--warm-autoload is also gated by trust (0.12.23)Non-interactive sessions skip untrusted features with a warning rather than hanging. Also: upgrade past 0.12.19 / 0.11.23 — CVE-2026-25129 is a CWD config-poisoning RCE and it affects Tinker too.
Bare command names now shadow PHP functions. config and copy resolve to PsySH commands before PHP function calls (0.12.22+). If you actually meant copy() from your own namespace, prefix with ; to force PHP evaluation:
;copy($a, $b)
Same issue applies to any future command name collision — ; is the universal escape hatch.
Semicolon return suppression is opt-in and can confuse you. Default behavior returns the value of every expression. If your team enables 'semicolonsSuppressReturn' => true, ending a line with ; prints nothing — which makes $user->posts; look broken to someone expecting output. 'double' mode requires ;;. Document whichever mode you pick, or leave it off.
Multi-line pasting is line-by-line without the new readline. In legacy readline, pasting a foreach block executes the first line immediately and errors. Either paste one statement at a time, or enable:
php artisan tinker --experimental-readline
The experimental engine adds bracketed paste, multi-line editing with auto-dedent, Ctrl+R reverse search, and a built-in pager. It's opt-in and still labeled experimental — expect occasional terminal quirks, but it's substantially better for anything involving closures or heredocs. If readline/libedit extensions are missing entirely, you get a degraded fallback; psy\info() tells you which engine is live.
Long sessions leak memory and stale state. Every model instance you create stays referenced by $_, history, and any variables you've bound. A 30-minute Tinker session against a large dataset will balloon. Restart when things get slow. Also remember $_ changes type after every command — don't rely on it surviving an unrelated expression.
config/tinker.php is not the same file as .psysh.php. Precedence, roughly: CLI flags → project .psysh.php (if trusted) → user config → Laravel's tinker.php → defaults. If a setting "doesn't take," check that you edited the layer that actually wins. --config <path> forces a specific file.
Exception formatting is version-sensitive. 0.12.24 fixed exception output eating the start of messages; earlier versions mangle errors. If wtf output looks truncated or a message is missing its prefix, that's the bug — upgrade. You can also supply an exceptionDetails closure in config to render extra context (e.g. validation error bags) alongside the message.
The pager can appear to hang. Long ls / dump output opens the pager; quitting it early used to emit pipe warnings (fixed in 0.12.22). Disable with config pager=false or --no-pager when scripting.
Debugging the shell itself:
psy\info() / --info — readline engine, autocomplete state, version--no-interaction — isolate whether a prompt is blocking youhistory --session (0.12.24+) — replay just this session's commands, useful for turning exploratory work into a real scriptedit — opens the current buffer in $EDITOR; Ctrl+C now interrupts it instead of waiting for the editor to closeExtension points worth knowing: Psy\Configuration (programmatic setup), Psy\Shell::setScopeVariables() / setBoundObject() / setBoundClass() (inject context), Psy\Command\Command (custom commands), casters (custom rendering), Psy\Exception\ThrowUpException (control what throw does in the shell — sudo re-runs with elevated access), and the config/copy runtime commands for per-session tweaks.
How can I help you explore Laravel packages today?