One of the barriers to using docker these days for some developers is the burden to interact with it daily instead of using the usual tools directly. Let’s see how we can ease things.

TL;DR

Instead of this:

$ docker exec -it -w '/var/www/html/src/Bundle/SomeBundle' myapp-php-1 php --version;

just this:

$ php --version;

A single per-project functions.sh that you source into your shell. It reads the .env docker compose already uses, then redefines php, composer, console & co. as shell functions forwarding to docker exec - right container, right working directory, all your arguments.

<TAB> completion keeps working, and answers from inside the container. unsource undoes everything when you’re done, without restarting your shell.

No Makefile, no docker compose exec to keep in your muscle memory.

Everything below is the why behind each line of that script - jump straight to the script if you’d rather read code first.

Table of Contents

  1. What’s this about?
  2. How does it work?
  3. How to use it?
  4. Bonus: container autocomplete
  5. Last notes

What’s this about?

In 2026, I believe a lot of us are using docker & docker compose on a daily basis. We see a lot of ways to interact with it. Some use the raw command line, some wrap it up in Makefiles, and there may be other ways.

Here are the constraints we have:

  • MUST work on multiple machines (ARM / AMD, Ubuntu, macOS, …)
  • MUST accept parameters
  • SHOULD NOT affect the current user setup too drastically
  • MUST be on a per-project basis to avoid side effects
  • MUST be opt-in
  • SHOULD make the developer forget about docker
  functions.sh Makefile Vanilla
Multi-Platform
Per-Project
Accept parameters
Daily usage easy partial hard
Learning curve easy medium hard
Extensibility easy hard -

Now I’ll present what I’ve been using for 5+ years now, boiled down to its essence for docker / PHP / Symfony users. Here is the script first, then I’ll explain.

#!/usr/bin/env bash

## ---------------
## Setup
## ---------------
_FUNCTIONS_CURRENT_BASH=$(ps -p $$ | tail -n 1 | awk '{ print $4 }' | perl -pe 's#.*[-/](\w)#$1#');
case "${_FUNCTIONS_CURRENT_BASH}" in
zsh)
    _FUNCTIONS_CURRENT_DIR=${0:A:h};
    ;;
bash)
    _FUNCTIONS_CURRENT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd);
    ;;
*)
    _FUNCTIONS_COLOR_RED='\033[0;31m';
    _FUNCTIONS_RESET_COLOR='\033[0m'
    echo -e "\n${_FUNCTIONS_COLOR_RED}\`${_FUNCTIONS_CURRENT_BASH}\` does not seem to be supported${_FUNCTIONS_RESET_COLOR}\n" 1>&2;
    return 1;
    ;;
esac

_FUNCTIONS_ENV_VARS=($(grep -oE '^[A-Za-z_][A-Za-z0-9_]*' "${_FUNCTIONS_CURRENT_DIR}/.env"));
source "${_FUNCTIONS_CURRENT_DIR}/.env";

_FUNCTIONS_DECLARED=();

unalias __declare_fn 2>/dev/null >/dev/null || true;
__declare_fn() {
    _FUNCTIONS_DECLARED+=("${1:?}");
    [[ "${_FUNCTIONS_CURRENT_BASH}" == 'bash' ]] && export -f "${1:?}";
    return 0;
}

unalias __getSubPath 2>/dev/null >/dev/null || true;
__getSubPath() {
    local absolute_subpath="${PWD}/";
    local subpath="";
    if [[ "${absolute_subpath}" == "${APP_PROJECT_PATH:?}/"* ]]; then
        subpath=${absolute_subpath#"${APP_PROJECT_PATH:?}/"};
    fi
    echo "${subpath}";
}

unalias __docker_exec 2>/dev/null >/dev/null || true;
__docker_exec() {
    local subpath; subpath=$(__getSubPath);
    local containerName="${1:?}"; shift;
    local isTty; [[ -t 0 && -t 1 ]] && isTty='t' || isTty='';

    local compEnv=();
    [[ -n "${COMP_LINE+x}" ]]  && compEnv+=(-e COMP_LINE);
    [[ -n "${COMP_POINT+x}" ]] && compEnv+=(-e COMP_POINT);
    [[ -n "${COMP_CWORD+x}" ]] && compEnv+=(-e COMP_CWORD);

    docker exec "${compEnv[@]}" -i"${isTty}" -w "/var/www/html/${subpath}" "${COMPOSE_PROJECT_NAME:?}-${containerName}-1" "$@";
}
__declare_fn __docker_exec;

unalias __load_completion 2>/dev/null >/dev/null || true;
__load_completion() {
    # Skip in non-interactive shells (make recipes, CI scripts, ...):
    # completion is pointless there and just adds overhead.
    [[ "$-" != *i* ]] && return 0;

    local cmd="${1:?}";
    local out;
    if [ "${_FUNCTIONS_CURRENT_BASH}" = "bash" ]; then
        out=$("${cmd}" completion bash 2>/dev/null) && eval "${out}";
    elif [ "${_FUNCTIONS_CURRENT_BASH}" = "zsh" ]; then
        out=$("${cmd}" completion zsh 2>/dev/null) && eval "${out}";
    fi
}
__declare_fn __load_completion;

## ---------------
## Alias factory
## ---------------
__define_docker_alias() {
    local name="${1:?}" container="${2:?}" cmd="${3:-$1}";
    unalias "${name}" 2>/dev/null >/dev/null || true;
    eval "${name}() { __docker_exec '${container}' '${cmd}' \"\$@\"; }";
    __declare_fn "${name}";
}

## ---------------
## PHP & Symfony
## ---------------
__define_docker_alias php php

__define_docker_alias composer tools
__load_completion composer;

__define_docker_alias symfony tools
__load_completion symfony;

unalias console 2>/dev/null >/dev/null || true;
console() { php bin/console "$@"; }
__declare_fn console;
__load_completion console;

unalias dev 2>/dev/null >/dev/null || true;
dev() { console --env=dev "$@"; }
__declare_fn dev;

unalias prod 2>/dev/null >/dev/null || true;
prod() { console --env=prod "$@"; }
__declare_fn prod;

## ---------------
## Teardown
## ---------------
unalias unsource 2>/dev/null >/dev/null || true;
unsource() {
    local name;
    local shell="${_FUNCTIONS_CURRENT_BASH}";
    local hard=''; [[ "${1:-}" == '--hard' ]] && hard='1';

    for name in "${_FUNCTIONS_DECLARED[@]}"; do
        if [[ "${shell}" == 'bash' ]]; then
            complete -r "${name}" 2>/dev/null;
        else
            compdef -d "${name}" 2>/dev/null;
        fi
        unset -f "${name}" 2>/dev/null;
    done
    unset "${_FUNCTIONS_ENV_VARS[@]}";
    unset -f __getSubPath __define_docker_alias __declare_fn;
    unset _FUNCTIONS_DECLARED _FUNCTIONS_ENV_VARS _FUNCTIONS_CURRENT_BASH _FUNCTIONS_CURRENT_DIR;

    [[ -n "${hard}" ]] && exec "${shell}";
    return 0;
}
__declare_fn unsource;

How does it work?

This is the tricky part that does the heavy magic. Basically we try to guess the current directory the functions.sh file is in. But because it’s a shell script, we have to guess using different methods depending on the shell of your choosing. Here I “support” only bash and zsh, and fail loudly on anything else instead of silently misbehaving.

Note the two branches don’t resolve _FUNCTIONS_CURRENT_DIR the same way, and that’s not just style. The bash branch uses a cd "$(dirname "${BASH_SOURCE[0]}")" && pwd subshell, which is safe there because PROMPT_COMMAND never fires inside a $(...) subshell. In zsh, though, that same pattern is dangerous: zsh subshells are full forks that inherit chpwd_functions, so if you (or a tool hooking cd) have anything registered there, cd-ing inside the substitution re-enters it recursively - and the directory-guessing variable ends up polluted by whatever that hook printed instead of a clean path. That’s why the zsh branch instead uses the native parameter modifier ${0:A:h} (:A resolves the absolute path, :h takes its dirname): no cd, no subshell, no risk of re-triggering chpwd_functions.

This script should work on any Mac / Ubuntu based distribution.

Firstly I assume that you have a .env in the same directory as the functions.sh file - the very same one docker compose itself reads (see https://docs.docker.com/compose/env-file/). Two variables matter here:

# .env
COMPOSE_PROJECT_NAME=myapp
APP_PROJECT_PATH=/absolute/path/to/myapp

COMPOSE_PROJECT_NAME isn’t specific to this script: it’s the standard docker compose variable that prefixes your containers, networks and volumes. Reusing it here means container names stay predictable (${COMPOSE_PROJECT_NAME}-<service>-1) without duplicating any configuration. APP_PROJECT_PATH, on the other hand, is specific to functions.sh: it must be the absolute path to your project root, since it’s what __getSubPath compares your current directory against.

__declare_fn does two things, and every single function the script defines goes through it. First, it’s a guard around export -f: bash exports functions to child processes through the environment (BASH_FUNC_*), but zsh has no equivalent mechanism - export -f there doesn’t fail, it just prints the function’s source to stdout as a side effect of typeset -f’s display mode, which would spam every terminal you open. __declare_fn skips the call entirely outside bash, so every other place in the script that needs to export a function calls it instead of export -f directly. Second, it records the name in _FUNCTIONS_DECLARED - that registry is what makes tearing everything down possible later, and it costs one line here because this function is already the mandatory choke point.

The trailing return 0 isn’t decorative either. [[ ... ]] && export -f ... is the last command of the function, so under zsh - where the test is false by design - __declare_fn would otherwise return 1 on every single call, which is a nasty thing to leave lying around for anyone who later chains it with && or runs the script under set -e.

The __getSubPath function is to detect if you are in a subdirectory of the APP_PROJECT_PATH. If so we use the relative subdirectory, empty otherwise.

The __docker_exec function is to facilitate the use of the previous function and the working directory (here I assume /var/www/html). It also checks whether both its standard input and standard output are attached to a real terminal to pick -i or -it - so the same alias works both from your shell and from non-interactive contexts like a script or CI (checking just standard input wouldn’t be enough - see Bonus: container autocomplete for why).

Finally we declare the aliases we want, through a small factory instead of repeating the same four lines for every tool. First we make sure that nothing prevents us from declaring the function by running unalias. Then __define_docker_alias declares a function which is what you would type (almost) if you were not using any wrapper of some sort, and accepts an infinite number of parameters thanks to "$@". Finally we make it globally available with __declare_fn.

cmd is meant to stay a single word - the factory doesn’t do any word-splitting of its own, it just substitutes ${cmd} once into the generated function body, so a value containing spaces would be passed to docker exec as one literal (and wrong) argument. Need yarn or any other single-binary tool you run through docker compose? Add one line:

__define_docker_alias yarn node

For a command that needs baked-in flags, like phpunit with a raised memory limit, skip the factory and declare a plain wrapper function instead, the same pattern as console above:

unalias phpunit 2>/dev/null >/dev/null || true;
phpunit() { php -dmemory_limit=-1 ./vendor/bin/phpunit "$@"; }
__declare_fn phpunit;

How to use it?

Once you’ve created the file, you will have to run the following every time you open a new bash session (new window, new tab, …).

$ source ./functions.sh

From now on if you type

user@server:~/myapp
$ php --version;

is equivalent to

user@server:~/myapp
$ docker exec -it -w '/var/www/html/' myapp-php-1 php --version;

and

user@server:~/myapp/src/Bundle/SomeBundle
$ php --version;

is equivalent to

user@server:~/myapp/src/Bundle/SomeBundle
$ docker exec -it -w '/var/www/html/src/Bundle/SomeBundle' myapp-php-1 php --version;

It will be running inside the container itself so you won’t have to think about it anymore.

Getting your shell back

Sourcing a file is a one-way operation: there’s no built-in “unsource”. So the script ships one:

user@server:~/myapp
$ unsource;

The tempting one-liner, and why it’s wrong

The obvious implementation is a single line - replace the shell with a brand new one of the same kind and let the old process take every definition with it:

unsource() { exec "${_FUNCTIONS_CURRENT_BASH}"; }

Nothing to enumerate, nothing to keep in sync. That’s what I ran for a long time, and it has two problems - one blunt, one genuinely sneaky.

The sneaky one: it does nothing at all under bash. export -f doesn’t mark a function for a fresh start - it serialises it into the environment, as a variable named BASH_FUNC_php%%. And exec preserves the environment. So the new bash dutifully re-imports every single one of them on startup:

$ source ./functions.sh
$ env | grep -o 'BASH_FUNC_[a-z_]*'
BASH_FUNC_php BASH_FUNC_composer BASH_FUNC_unsource
$ unsource        # exec bash
$ type -t php
function          # ... still here

The reason this bug can go unnoticed for years is that it’s invisible in zsh: __declare_fn skips export -f there, so nothing survives the exec and everything looks fine. The shell where the export actually happens is the one where the teardown silently doesn’t.

The blunt problem applies even where the exec does work. It’s a fresh process, so everything else session-local goes with the functions: shell variables you set by hand, cd history, background jobs. And since the replacement shell re-reads your rc files, sourcing functions.sh from .bashrc/.zshrc makes unsource a no-op - the new shell immediately sources it back.

Actually undoing it

The usual objection to a real teardown is bookkeeping: unset -f on every function, and a list to keep in sync the day you add an alias. That objection doesn’t hold here, because __declare_fn is already the mandatory choke point every declaration goes through. Making it record the name costs one line, and the list can never drift.

From there unsource walks the registry:

for name in "${_FUNCTIONS_DECLARED[@]}"; do
    ...
    unset -f "${name}" 2>/dev/null;
done

unset -f removes the function and its export, which is exactly what the exec failed to do. The completion deregistration in the same loop (complete -r in bash, compdef -d in zsh) handles something the exec masked entirely: completions are registered against the literal word composer, and they outlive the function. Left behind, they’d keep firing against whatever composer now resolves to on your host.

The .env variables are read back from the file itself rather than hardcoded, so that list can’t drift either:

_FUNCTIONS_ENV_VARS=($(grep -oE '^[A-Za-z_][A-Za-z0-9_]*' "${_FUNCTIONS_CURRENT_DIR}/.env"));

An array, not a space-separated string: zsh doesn’t word-split unquoted parameter expansions, so unset ${string} there fails with invalid parameter name. Arrays behave identically in both shells.

Two details in the final unset -f line. It lists only __getSubPath, __define_docker_alias and __declare_fn - the three functions that don’t go through __declare_fn, and so aren’t in the registry. Adding the others would make zsh complain no such hash table element about names the loop already removed. And unsource is deliberately absent from that line too: it registers itself with __declare_fn unsource, so it’s removed by its own loop, mid-execution. Both shells finish executing a function body whose definition has just been deleted, so this is safe - the parsed body stays alive until it returns.

Keeping the big hammer, as a flag

The exec still has one legitimate use: the day your session is genuinely wedged and you want a guaranteed-clean slate. So it stays, as unsource --hard - and crucially it composes with the teardown instead of replacing it. The unset pass runs first, which strips the BASH_FUNC_* entries from the environment, and only then does the exec happen, on an environment that’s already clean:

[[ -n "${hard}" ]] && exec "${shell}";

That’s also why the shell name is copied into a local shell at the top of the function: _FUNCTIONS_CURRENT_BASH has been unset by the time we get here.

A flag rather than a second command, for two reasons. unsource earns its keep by being the literal antonym of source - the one command you typed to get into this state - and a script whose whole premise is squatting common words (php, composer, console, dev, prod) should spend its collision budget carefully. reset, the tempting name for the hard variant, is a particularly bad trade: it’s a real binary (/usr/bin/reset, the ncurses terminal reset). You want it available precisely when a container command has just vomited binary into your terminal - which is to say, while functions.sh is sourced.

There’s no reload either, and none is needed: re-sourcing is already idempotent. _FUNCTIONS_DECLARED is reset at the top of the file and every declaration starts with its own unalias, so after editing the script, source ./functions.sh again and you’re done - no duplicated registry entries.

Bonus: container autocomplete

By default, wrapping a CLI tool in a shell function drops whatever autocomplete it used to have - <TAB><TAB> falls back to your host’s completion, or nothing at all. That’s fixable, just not uniformly across every tool, and not automatically for php itself.

Why it breaks in the first place

Bash and zsh completion is registered against a literal word: complete -F ... composer in bash, compdef ... composer in zsh. Once composer is a shell function instead of a real binary on your PATH, none of that registration exists anymore - your shell falls back to filename completion, or nothing at all.

Two families of tools

Fixing this depends entirely on how the wrapped tool implements its own completion:

  • Self re-exec tools - Composer, bin/console, npm, and most Go CLIs built with Cobra (kubectl, terraform, gh, …) - ship a <tool> completion bash|zsh subcommand. The generated script contains no logic of its own: on every <TAB> it re-invokes the exact word you typed (composer, console, …), either with a couple of environment variables (COMP_LINE, COMP_POINT) - npm’s protocol - or with extra positional arguments, through a _complete subcommand for Symfony-console-based tools (Composer, bin/console) or a hidden __complete subcommand for Cobra tools. Since that word resolves to your shell function, and your function forwards to docker exec, this can be made to work end to end.
  • Shell-out-direct tools - psql via the bash-completion package is the classic example - need none of this: their completion script just calls the wrapped command by name for the dynamic bits (e.g. psql -Atqc 'select datname from pg_database' to list databases). Since that name already resolves to your shell function, it works as soon as bash-completion is installed on your host - no changes to functions.sh required.
  • Plain binaries with no subcommand-based completion of their own (php, node) don’t benefit from either technique above - there’s no protocol to reuse, so php <TAB><TAB> stays limited to filename completion out of the box. That’s not a dead end though: nothing stops you from hand-writing a small static completion function for them, the same way bash-completion ships one for psql - a fixed list of known flags via compgen -W, and file completion narrowed to *.php/*.js instead of “everything”. Any genuinely dynamic part (e.g. listing the extensions actually loaded, for php -d) would still need to shell out to the containerized binary - php -m, here - to reflect the container’s install rather than the host’s, whether or not it even has PHP.

Making the self re-exec family work

This takes two changes to __docker_exec. First, since docker exec doesn’t forward your shell’s environment by default, the COMP_LINE/COMP_POINT variables that npm’s completion relies on have to be forwarded explicitly - that’s what the -e VAR flags do (omitting =value tells docker exec to forward the variable’s current value from your shell). Symfony-console-based tools and Cobra-style tools don’t need this at all, since they pass everything as regular arguments, already carried through by "$@".

Second, isTty needs to check more than just standard input. Testing only standard input (with the tty command) is the natural first instinct, but it falls short here: when you generate a completion script with eval "$(composer completion bash)", standard input is still your terminal, while standard output is being captured by the command substitution - it isn’t a terminal at all. Asking docker exec for a pseudo-TTY (-t) in that situation corrupts the captured text with stray carriage returns, and eval chokes on it with cryptic errors like parse error near 'done'. Checking both file descriptors avoids it: [[ -t 0 && -t 1 ]].

Generating and registering the completion script itself just needs a tiny helper, so the same one-liner works for every tool that supports it - that’s __load_completion above. The 2>/dev/null matters: if a tool has no completion subcommand, or the container isn’t running yet, the inner call fails and out stays unset - the && short-circuits before eval ever runs, instead of evaluating whatever partial output the failed call may have written. Sourcing functions.sh still never fails, even with Docker down - only the completion (or the command itself) would be missing.

__load_completion also bails out immediately in non-interactive shells, checked with [[ "$-" != *i* ]] ($- lists the shell’s active option flags, and i is only among them for an interactive one). functions.sh doesn’t just get sourced in a terminal - a Makefile recipe or a CI script can source it too, purely to reuse the php/composer/console aliases, with no <TAB> completion ever in play there. Without this guard, every one of those non-interactive sources would still shell out to docker exec once per tool just to generate a completion script nobody uses - pure overhead. The check is cheap and runs before any of that work starts.

One portability trap worth flagging if you extend this further: bash’s indirect parameter expansion (${!var+x}, used to check a variable by name stored in another variable) is bash-specific and breaks zsh with bad substitution. That’s why __docker_exec above checks COMP_LINE, COMP_POINT and COMP_CWORD explicitly, one line each, instead of looping over a list of names with indirection - three lines is barely more verbose, and it stays portable to both shells.

What’s still out of reach

Wrapper functions that don’t go through docker exec at all - say, one that shells out to a local binary after cd-ing somewhere else - need their own handling: the completion machinery runs in your current shell and current directory, not wherever the wrapped command ends up executing, so you may need to cd temporarily inside the completion callback itself. If something else also hooks cd in your shell - an async prompt, a direnv-like tool - that hook can fire in the middle of your completion callback and misbehave. Neutralizing it for the duration (clearing chpwd_functions in zsh, or the DEBUG trap in bash, then restoring it right after) avoids the interference, but it’s enough extra ceremony that I’ve kept it out of the snippet above.

Last notes

For a long time, I had to manually source / unsource this script every time I opened a new terminal window.

Writing a real teardown is what made the missing piece obvious. The registry-and-unset dance isn’t only about being able to type unsource yourself - it’s precisely what a directory-aware tool has to run on your behalf when you leave the project. The exec version could never have been that: you can’t restart someone’s shell because they cd ..-ed out of a directory, and you certainly can’t do it twice a minute. Once undoing a source is a plain function call that leaves the rest of the session untouched, hooking it to directory changes stops being a hack and becomes just… a function call.

I tried ondir, but it’s considered feature complete and hasn’t received any updates in several years.

I’m now building a dedicated tool for that, which will get its own write-up: envoke (feedback welcome).