Emacs Guide for PHP Development in 2025 and Beyond

If you're new to php development (and its ecosystem), and you want to write php in emacs, this article is for you. I won't cover the language much but I will cover tooling, packages, etc., and getting them to work with emacs. (gifs/videos included!)

As with most setups, I usually start by looking at the spacemacs layer for a language (php).

Here's a short list of things we'll cover:

Before jumping in, just know that some settings are right for one project, but not for another. If you need project specific settings, read about dir-locals in my article or check out the manual.

Jump to tl;dr if you came here to copy and paste.

Tooling Outside Emacs

Here are the tools emacs interfaces with, in brief. You'll want to install the relevant items so emacs can find them. I use yay, but replace with your packaging tool of choice like brew, apt, or whatever else.

Package Management (composer and packagist)

composer is the tool for managing packages (pecl is another one, but composer is de facto according to some). It's what npm is for node.js, gem is for ruby, leiningen is for Clojure.

It also uses a composer.json file, specific to each project. (Like a package.json, or a bundle file.)

Packagist hosts the packages for easy downloading from command line.

To install (if using yay)

yay -S composer

Verify your install:

composer --version

PHP Settings (php.ini)

This is the file that specifies modules active in the language. On my linux distro, it's at /etc/php/php.ini. I don't know another language that does it like this. I'm used to specifying what modules I'm importing in a file and that's it. Here, you have to make it available in what is essentially a giant config file.

In php, people tend to just edit this php.ini–and you usually need root permissions to do it.

There's also .user.ini, which only works for some cases. See the PHP manual: https://www.php.net/manual/en/configuration.file.per-user.php and pay more attention to the user contributed notes, which are clearer than the official doc.

In the next section, you'll be modifying php.ini, so the php runtime can load the xdebug module.

xdebug 3 (php debugger)

Step breakpointing requires xdebug.

Unlike many kinds of debuggers, xdebug requires that you run a server that it will connect to (xdebug will act as a client, not a server). This will be important when we talk about step through debugging in emacs.

For now, you need these lines to either be included in your php.ini file directly, or indirectly (by making php.ini include another file with these lines, like an xdebug.ini).

zend_extension=xdebug.so
xdebug.mode = debug
xdebug.start_with_request = yes # trigger is also a good value, read the docs
xdebug.log="/home/jwow0/tmp/xdebug.log" # or wherever you want these logs
xdebug.client_port=9003 # you can omit this usually, the default port is 9003

If this doesn't work, check the docs: https://xdebug.org/docs/step_debug#configure We'll go over xdebug over docker in the Step Breakpointing (dape) section.

Install:

yay -S xdebug

Then setup your ini file like above.

To verify you have xdebug installed:

php -m -c

should output some lines that include

[Zend Modules]
Xdebug

repl (psysh)

The built in repl, php -a, is bad. Do not use it. Use pysh. It's a repl that looks and feels as powerful as a ruby pry shell. You can look up documentation, skip writing those semi-colons, and get instrospection of object automatically without having to var_dump(obj). The autocomplete and font coloring are also appreciated.

Psysh has many ways to install I won't cover: https://github.com/bobthecow/psysh/wiki/Installation

To install with composer per project:

composer require --dev psy/psysh:@stable

To install globally:

composer global require psy/psysh:@stable

For global installs, you'll want to also add the psysh executable in path:

echo "PATH=\$PATH:~/.config/composer/vendor/bin" >> ~/.profile

After sourcing your ~/.profile can run it simply with

psysh

Try getting docs for str_split:

> doc str_split

Linter + Fixer

Multiple linter and fixer programs exist. I recommend you go with php-cs-fixer or phpcbf, but if you're joining a mature project you have little choice.

Later I'll show you how to auto-lint on save which requires you have phpcbf.

You can install phpcbf into your project

composer require --dev squizlabs/php_codesniffer 

Or install php-cs-fixer into your project:

composer require --dev friendsofphp/php-cs-fixer

Just note that there isn't a php-cs-fixer emacs package, but since they both work on a common standard, there should be little if any difference in how they lint/fix.

Testing Framework (phpunit)

Your testing framework, as you would expect, should be per project since various projects are likely to have various versions.

I'm not aware of any other testing frameworks in php other than phpunit (and I consider that a good thing!1).

Running

composer require phpunit

yields choices

composer require phpunit
Could not find package phpunit.
Pick one of these or leave empty to abort:
  [0] phpunit/phpunit
  [1] phpunit/php-timer
  [2] phpunit/php-invoker
  [3] brianium/paratest
  [4] symfony/phpunit-bridge
 > 

because the full specification was phpunit/phpunit. That's kinda nice!

Language Server (phpactor)

You need the standalone program that acts as a server that emacs will talk to over jsonrpc.

yay -S phpactor

I recommend phpactor because I trust Usami Kenta (aka zonuexe), maintainer of php-mode and longtime php hacker: Usami's recommendation.

Emacs Packages

Let's see how the tools we installed above can integrate seamlessly in emacs!

PHP Major Modes

There are three major modes I recommend, depending on your preferences.

tl;dr: I recommend using php-mode or php-ts-mode when you're in a pure php file. If you're dealing mainly with an html or html templating language in a file, choose web-mode.

php-mode

Find notes on configuring at https://github.com/emacs-php/php-mode. php-mode also detects your linter too (php-cs-fixer, phpcbf, or ecs).

(use-package php-mode
  :after eglot
  :config 
  ;; if you use flymake
  (add-hook 'php-mode-hook #'flymake-mode)
  (add-to-list 'auto-mode-alist '("\\.php\\'" . php-mode))

  (custom-set-variables
   '(php-mode-coding-style 'PSR12)
   '(php-mode-template-compatibility nil)
   '(php-imenu-generic-expression 'php-imenu-generic-expression-simple))
  )

This mode is fully featured to integrate with other tools, and strives to give you a full IDE experience. In the config above, I have not enabled all those tools. See https://github.com/emacs-php/php-mode?tab=readme-ov-file#personal-settings.

php-ts-mode

Use the tree-sitter-powered php-ts-mode as a drop-in replacement for php-mode. It ships with emacs, and has a small performance cost since the a parser is running all the time in your file. But it also doesn't pull in a lot of niceties that php-mode comes with, like auto-detecting your php coding standards.

To activate, you need the grammar, so run

(add-to-list 'treesit-language-source-alist '(php "https://github.com/tree-sitter/tree-sitter-php"))

Then M-x treesit-install-language-grammar and pick php.

If you want to wrap your settings in a use-package, you can

(use-package php-ts-mode
  :config
  ;; set your settings here
  ;; Note that this will conflict with php-mode, since you can only have 1 major mode at a time in emacs
  (add-to-list 'auto-mode-alist '("\\.php\\'" . php-ts-mode))
  )

Cosmetically, it has different font choices than php-mode. Functionally, it's much more accurate if you want to jump to a location in the code. Definitely recommended if you want to build on top, and say, bind keybindings to do things to a class, function, string, etc.

web-mode

(use-package web-mode
  :config
  (add-auto-mode 'web-mode
                 "*html*" "*twig*" "*tmpl*" "\\.erb" "\\.rhtml$" "\\.ejs$" "\\.hbs$"
                 "\\.ctp$" "\\.tpl$" "\\.njk$"
                 "/\\(views\\|html\\|templates\\)/.*\\.php$")
  (add-hook 'web-mode-hook #'eglot-ensure))

Which Should You Pick?

PHP was a language built for p ersonal h ome p ages. Still to this day, in a .php file, you specify which part is php, and which isn't.

PHP actually functions quite natively as a templating language for html. As a result of this legacy, there are many cases where a PHP file is pure php, and those that aren't.

Use web-mode, when you are dealing with mixed html and php, and php-mode (or php-ts-mode if you want to use treesitter) when it's just php. In web-mode, it's possible to get language server support for the php portions of your code, and not have it impact your html. Keep in mind your keybindings will be web-mode keybindings in this case, so I still recommend using a php major mode in a pure php file.

You may occasionally want to set the mode manaully when visiting a file, but you can also use .dir-locals to define the mode for a subdirectory. This works well when php template files are in separate directories than pure php app code files.

Language Server (eglot)

eglot-quick-fix-action.gif
Figure 1: gif of using eglot to remove the unused SplArray import

Eglot just works provided you have an lsp backend like phpactor findable in your search $PATH. It will autodetect it and manage it on its own.

eglot gives auto-complete, code-jumping to source, suggestions, and refactoring shortcuts. You'll also be prompted to enable certain extensions if inside the project you have certain packages installed:

eglot-detects-php-codesniffer.png
Figure 2: image of eglot asking if I want to enable an extension for php code sniffer (phpcbf)

If you only cared about code-jumping to source I'd still recommend eglot but you can of course just build ETAGS which is lightning fast.

Here's a commented config:

(use-package eglot
  ;; I tell straight to use the built-in eglot that ships with emacs
  ;; omit the following line if you don't use straight
  :straight (:type built-in) 
  :hook
  ;; When eglot is active, I like to use company and yasnippet. Omit if you don't use these packages
  (eglot-managed-mode-hook . (lambda ()
                               (add-to-list 'company-backends '(company-capf :with company-yasnippet)
                                            't)))
  ;; I use yasnippet, and I need company-yasnippet to be defined
  ;; prior to setting the hook. If you do not use company nor yasnippet, 
  ;; remove the following line:
  :after (yasnippet company)
  :config
  ;; For web-mode; eglot can do its job in the php sections of your code
  (add-to-list 'eglot-server-programs
               '((web-mode :language-id "php") . ("phpactor" "language-server")))

  ;; For php-mode
  (add-hook 'php-mode-hook #'eglot-ensure)
  ;; When you waant to use php-ts-mode, you'll want eglot to activate too.
  (add-hook 'php-ts-mode-hook #'eglot-ensure))

Auto-Lint Fix On Save (phpcbf)

phpcbf-auto-fix.gif
Figure 3: a gif of me saving a php file, and code is automatically formatted to PSR12

To integrate with your linter/fixer, you'll typically set which coding standard (a named set of code format rules, like es2020 for js). PSR-4, PSR-2, PSR-12, and PEAR, are some common names you'll see.

If there's a phpcs.xml, you'll want to conform to what it says for the project.

There's also more than one program you can use for linting and fixing (php-cs-fixer) and to be honest, others can tell you the differences better.

Most of the time, a project's composer.json will explicitly state the code format standard used (check "autoload" or if there's a linting script in "scripts").

For auto format on save, I recommend using the emacs phpcbf package.

(use-package phpcbf
  :config
  (add-hook 'php-mode-hook #'phpcbf-enable-on-save)
  (add-hook 'php-ts-mode-hook #'phpcbf-enable-on-save)
  ;; Set to nil means use the phpcs.xml in the project
  (setq phpcbf-standard nil)
  )

If you don't have phpcbf in your project, you can install it globally.

Assuming you want to use a specific standard per project (if there's no phpcs.xml file in your project), you can use a dir local.

((php-mode . ((phpcbf-standard . "PSR12")
              (phpcbf-executable . "~/src/php-proj/vendor/bin/phpcbf"))))

Yes, it is confusing that we call the standard "PSR-12" but the string is in fact "PSR12".

Autocompletion (company)

company-php.gif
Figure 4: autocompletion with company
(use-package company-php)

;; if you want, you can put this inside the use-package of eglot
(define-key eglot-mode-map (kbd "<tab>") 'company-indent-or-complete-common)

I recommend readers stick with their completion framework. Corfu is another good choice and might work even better with the dape repl2.

I don't recommend my company setup, but here it is for transparency:

(use-package company
  :after (yasnippet eglot)
  :hook ((prog-mode . company-mode)
         (org-mode . company-mode))
  :bind (:map company-active-map
              ("<tab>" . company-complete-selection))
  :custom
  (company-minimum-prefix-length 1)
  (company-idle-delay 0.3)
  :config
  (global-set-key (kbd "C-o") #'company-complete))

(use-package company-box
  :hook (company-mode . company-box-mode))

Step Breakpointing (dape)

A video of myself using dape to debug PHP code, change variables, and control execution.

Dape. It's the newer alternative to dap-mode, and it strives to depend only on emacs built-in libraries.

Before you can M-x dape successfully, you need to follow the instructions for dape to work with xdebug, including downloading a vs-code extension. Why? Because the extension sets up a server that xdebug can talk to. Dape then talks to the vs-code extension.

Here's a minimal use-package incantation:

(use-package dape
  :hook
  ;; Save breakpoints on quit
  (kill-emacs . dape-breakpoint-save)
  ;; Load breakpoints on startup
  (after-init . dape-breakpoint-load))

I recommend you read the readme, as it has a use-package declartion decorated with comments on the various options.

Once you have have dape, xdebug, and the vs code exension, you can type M-x dape then type xdebug.

If something's not working you can debug further by

  • reading through logs you set in the xdebug.log. The logs will note if it can find the server it's looking for, and what breakpoints xdebug knows about.
  • In your php file, place phpinfo() at the top of your file. This should list all the modules available in your php runtime. xdebug info should be included if it's in the runtime.
  • In your php file, place xdebug_info() at the top of your file. It should print out how xdebug is configured.
  • Check if xdebug can work using just the command line. The shell command I use in the video above: php -d xdebug.profiler_enable=On hello.php.
  • setting dape-debug to t and read dape's debug messages. Pay attention to the *dape-repl* and *dape-connection events* buffers.

Breakpointing Through Docker

Say your app runs in docker. And it has a docker volume that mounts code from your host machine. You do this because your container can run the code as you're changing it, giving you a better sense of behavior.

That means your xdebug must also run in the docker container. You now have two options: should you breakpoint as if the code is local to the container? Or local to your host machine?

I'm not an expert on your system, but I think it's way more convenient to debug the code on your host machine, with all your tools set up, and your accustomed web-browser.

You need two things in this case:

  1. Update the xdebug.ini used in your docker container (or php.ini if you wrote the config in that):
    # include these two lines
    xdebug.discover_client_host=1
    # linux can use 172.17.0.1; mac and windows use host.docker.internal
    xdebug.client_host=172.17.0.1 
    # You may also need to change where the xdebug.log is set, so the process in the container
    # can write logs
    
  2. From dape, you may need to tweak the settings in the Run adapter prompt.

    Once you M-x dape, you can set command-cwd, prefix-local, and prefix-remote like so:

    xdebug command-cwd "~/" prefix-local "/home/jwow0/src/php-proj/" prefix-remote "/var/www/html/"
    

    Note that this assumes /home/jwow0/src/php-proj/ is mounted in the docker volume as /var/www/html/.

    If you want to save that as a preset, you can explicity set a config in elisp.

    (add-to-list 'dape-configs
                 `(my-xdebug-config
                   modes (php-mode php-ts-mode)
                   command-cwd "~/"
                   prefix-local "/home/jwow0/src/php-proj/"
                   prefix-remote "/var/www/html/"
                   ensure (lambda (config)
                            (dape-ensure-command config)
                            (let ((dap-debug-server-path
                                   (car (plist-get config 'command-args))))
                              (unless (file-exists-p dap-debug-server-path)
                                (user-error "File %S does not exist" dap-debug-server-path))))
                   command "node"
                   command-args (,(expand-file-name
                                   (file-name-concat dape-adapter-dir
                                                     "php-debug"
                                                     "extension"
                                                     "out"
                                                     "phpDebug.js")))
                   :type "php"
                   :port 9003))
    

    Then M-x dape should let you use the option my-xdebug-config.

    dape-with-custom-xdebug-config.gif
    Figure 5: a gif of myself running M-x dape, then typing my-xdebug-config for the "Run adapter" prompt.

    If you can't get the debugger to snag, you'll want to look at where you set the xdebug.log to, and tail -f that file.

Test Runner Shortcuts (phpunit)

Assuming you use phpunit, use phpunit.el!

A video of myself using dape to debug PHP code, change variables, and control execution.

(I remember there being an emacs tree-sitter test runner package somewhere. It does not show up in search however and I lost track of it.)

(use-package phpunit
  :config
  (define-key php-mode-map (kbd "C-t t") #'phpunit-current-test)
  (define-key php-mode-map (kbd "C-t c") #'phpunit-current-class)
  (define-key php-mode-map (kbd "C-t p") #'phpunit-current-project))

You should be testing your apps, especially if they reach a certain size. One way to lower friction for adding tests is to make running tests joyful. You shouldn't be switching to terminal back and forth in a separate window, losing precious cycles to context switching.

For example, if your point is enclosed by a test case.

REPL (pysh.el)

If you prefer using psysh from emacs, there's psysh.el, which integrates with eldoc:

(use-package psysh)

You can use all your emacs shortcuts in the comint buffer too.

Not covered

  • php-stan and phpstan.el for static type analysis.

tl;dr

Here's a yay command (apt-get probably works too, but as with distros, the package names may be different) to grab the packages:

yay -S php phpactor xdebug composer php-sqlite php-cgi php-gd php-imagick
# tbh I forgot if the last 3 were needed for apache and wordpress instead, if you were going to use them
# yay -S apache php-apache # uncomment this line if you want to install apache, which gives the apache server

And here are all the use-package incantations:

(use-package php-mode
  :after eglot
  :config 
  ;; if you use flymake
  (add-hook 'php-mode-hook #'flymake-mode)
  (add-to-list 'auto-mode-alist '("\\.php\\'" . php-mode))

  (custom-set-variables
   '(php-mode-coding-style 'PSR12)
   '(php-mode-template-compatibility nil)
   '(php-imenu-generic-expression 'php-imenu-generic-expression-simple))
  )

(add-to-list 'treesit-language-source-alist '(php "https://github.com/tree-sitter/tree-sitter-php"))
(treesit-install-language-grammar 'php)
(use-package php-ts-mode
  :config
  ;; set your settings here
  ;; Note that this will conflict with php-mode, since you can only have 1 major mode at a time in emacs
  (add-to-list 'auto-mode-alist '("\\.php\\'" . php-ts-mode))
  )

(use-package web-mode
  :config
  (add-auto-mode 'web-mode
                 "*html*" "*twig*" "*tmpl*" "\\.erb" "\\.rhtml$" "\\.ejs$" "\\.hbs$"
                 "\\.ctp$" "\\.tpl$" "\\.njk$"
                 "/\\(views\\|html\\|templates\\)/.*\\.php$")
  (add-hook 'web-mode-hook #'eglot-ensure))

(use-package eglot
  ;; I tell straight to use the built-in eglot that ships with emacs
  ;; omit the following line if you don't use straight
  :straight (:type built-in) 
  :hook
  ;; When eglot is active, I like to use company and yasnippet. Omit if you don't use these packages
  (eglot-managed-mode-hook . (lambda ()
                               (add-to-list 'company-backends '(company-capf :with company-yasnippet)
                                            't)))
  ;; I use yasnippet, and I need company-yasnippet to be defined
  ;; prior to setting the hook. If you do not use company nor yasnippet, 
  ;; remove the following line:
  :after (yasnippet company)
  :config
  ;; For web-mode; eglot can do its job in the php sections of your code
  (add-to-list 'eglot-server-programs
               '((web-mode :language-id "php") . ("phpactor" "language-server")))

  ;; For php-mode
  (add-hook 'php-mode-hook #'eglot-ensure)
  ;; When you waant to use php-ts-mode, you'll want eglot to activate too.
  (add-hook 'php-ts-mode-hook #'eglot-ensure))

(use-package company-php)

;; if you want, you can put this inside the use-package of eglot
(define-key eglot-mode-map (kbd "<tab>") 'company-indent-or-complete-common)

(use-package dape
  :hook
  ;; Save breakpoints on quit
  (kill-emacs . dape-breakpoint-save)
  ;; Load breakpoints on startup
  (after-init . dape-breakpoint-load))

(add-to-list 'dape-configs
             `(my-xdebug-config
               modes (php-mode php-ts-mode)
               command-cwd "~/"
               prefix-local "/home/jwow0/src/php-proj/"
               prefix-remote "/var/www/html/"
               ensure (lambda (config)
                        (dape-ensure-command config)
                        (let ((dap-debug-server-path
                               (car (plist-get config 'command-args))))
                          (unless (file-exists-p dap-debug-server-path)
                            (user-error "File %S does not exist" dap-debug-server-path))))
               command "node"
               command-args (,(expand-file-name
                               (file-name-concat dape-adapter-dir
                                                 "php-debug"
                                                 "extension"
                                                 "out"
                                                 "phpDebug.js")))
               :type "php"
               :port 9003))

(use-package phpunit
  :config
  (define-key php-mode-map (kbd "C-t t") #'phpunit-current-test)
  (define-key php-mode-map (kbd "C-t c") #'phpunit-current-class)
  (define-key php-mode-map (kbd "C-t p") #'phpunit-current-project))

(use-package psysh)

Enjoy!

Conclusion

And that should be it. Hopefully this helps you understand what tools are commonly used in the php ecosystem, the emacs tools that integrate with them, as well as a sense of where to debug should things go awry.

What's your favorite php integration with emacs?

If you'd like more articles like this, consider supporting me with a donation, help me find employment, and check out my work at https://codeberg.org/MegaJ. Writing these articles, recording gifs and videos, and debugging take hours. Your support is tremendously appreciated.

Stay warm!


1

Consider the javascript ecosystem, in excess of 1 million packages, a lot of them doing the same thing. It's hard to choose a package. PHP however, has an ecosystem where core packages are maintained, and remains lean but durable.

In php, small projects will not bring in hundreds of dependencies like in node.js, leading to half a gig of node_modules for a basic react app. You have something more modest, in tens of megabytes perhaps, and of course more for larger applications.

2

I was unable to get company working in the dape repl.