Setting Up Emacs for PHP Development: LSP, Tree-sitter, and Xdebug

Emacs is a serious PHP development environment once you wire the right packages together. This guide covers a complete, production-grade Emacs setup for PHP in 2026: language server integration with Eglot, Tree-sitter syntax highlighting, code formatting, and step-through debugging via DAP Mode and Xdebug 3. All configuration targets Emacs 29+ with use-package.

Prerequisites

  • Emacs 29.1 or newer (built with tree-sitter support)
  • PHP 8.2+ on the host or in Docker
  • Intelephense or phpactor language server
  • Xdebug 3 installed in your PHP environment

Tree-sitter Grammar for PHP

Emacs 29 ships php-ts-mode in contrib. First, install the grammar:

;; In your init.el — run once interactively, then keep for new machines
(setq treesit-language-source-alist
      '((php "https://github.com/tree-sitter/tree-sitter-php" "main" "php/src")))

(treesit-install-language-grammar 'php)

Enable the mode automatically:

(use-package php-ts-mode
  :ensure nil ; built-in since Emacs 30, or install php-mode from MELPA for 29
  :mode ("\\.php\\'" "\\.phtml\\'")
  :hook (php-ts-mode . eglot-ensure))

If you are on Emacs 29 and php-ts-mode is not available, install php-mode from MELPA and add the tree-sitter font-lock upgrade manually:

(use-package php-mode
  :ensure t
  :mode ("\\.php\\'" "\\.phtml\\'")
  :hook ((php-mode . eglot-ensure)
         (php-mode . (lambda ()
                       (when (treesit-available-p)
                         (treesit-parser-create 'php))))))

Language Server: Intelephense via Eglot

Intelephense is the most capable PHP language server. Install it globally with npm:

npm install -g intelephense

Eglot knows about Intelephense out of the box. The only configuration you need is to point it at your project and optionally pass a licence key for premium features:

(use-package eglot
  :ensure nil
  :bind (:map eglot-mode-map
              ("C-c r" . eglot-rename)
              ("C-c a" . eglot-code-actions)
              ("C-c f" . eglot-format-buffer)
              ("M-."   . xref-find-definitions)
              ("M-,"   . xref-go-back))
  :config
  ;; Pass initializationOptions for Intelephense
  (setq-default eglot-workspace-configuration
                '((:intelephense
                   :files (:maxSize 5000000)
                   :environment (:phpVersion "8.2")
                   :format (:enable t)
                   :licenceKey "YOUR_KEY_HERE")))

  ;; Increase timeout for large Drupal codebases
  (setq eglot-connect-timeout 60)

  ;; Don't log full LSP traffic in production use
  (setq eglot-events-buffer-size 0))

Using phpactor Instead

phpactor is open-source and understands Drupal container configuration. Install it via Composer:

composer global require phpactor/phpactor

Register it with Eglot:

(with-eval-after-load 'eglot
  (add-to-list 'eglot-server-programs
               '(php-mode . ("phpactor" "language-server")))
  (add-to-list 'eglot-server-programs
               '(php-ts-mode . ("phpactor" "language-server"))))

Code Style: PHP CS Fixer

Install PHP CS Fixer project-locally and wire it as a formatter:

composer require --dev friendsofphp/php-cs-fixer
(use-package reformatter
  :ensure t
  :config
  (reformatter-define php-cs-fix
    :program "vendor/bin/php-cs-fixer"
    :args (list "fix" "--using-cache=no" "-"))
  :hook (php-mode . php-cs-fix-on-save-mode)
  :hook (php-ts-mode . php-cs-fix-on-save-mode))

Your .php-cs-fixer.php at project root controls rules:

<?php
// .php-cs-fixer.php
use PhpCsFixer\Config;
use PhpCsFixer\Finder;

$finder = Finder::create()
    ->in(__DIR__ . '/web/modules/custom')
    ->in(__DIR__ . '/web/themes/custom')
    ->name('*.php');

return (new Config())
    ->setRules([
        '@PSR12' => true,
        'array_syntax' => ['syntax' => 'short'],
        'ordered_imports' => ['sort_algorithm' => 'alpha'],
        'no_unused_imports' => true,
        'trailing_comma_in_multiline' => true,
    ])
    ->setFinder($finder);

Xdebug 3 Setup

Installing Xdebug in Docker

Add to your PHP Dockerfile:

RUN pecl install xdebug \
    && docker-php-ext-enable xdebug

In docker-compose.yml, pass configuration via environment variables — the cleanest approach for toggling Xdebug without rebuilding:

services:
  php:
    build: .
    environment:
      XDEBUG_MODE: "debug"
      XDEBUG_CONFIG: "client_host=host.docker.internal client_port=9003 start_with_request=yes"
      PHP_IDE_CONFIG: "serverName=drupal-local"

For local (non-Docker) PHP, add to php.ini or conf.d/xdebug.ini:

[xdebug]
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.start_with_request=yes
xdebug.client_host=127.0.0.1
xdebug.client_port=9003
xdebug.log=/tmp/xdebug.log
xdebug.idekey=emacs

DAP Mode: Step-Through Debugging in Emacs

DAP Mode implements the Debug Adapter Protocol. For PHP it delegates to vscode-php-debug.

npm install -g @xdebug/vscode-php-debug
(use-package dap-mode
  :ensure t
  :after eglot
  :config
  (require 'dap-php)
  (dap-mode 1)
  (dap-ui-mode 1)
  (dap-tooltip-mode 1)
  ;; Show variable values in fringe
  (dap-ui-controls-mode 1)

  (dap-register-debug-template
   "PHP: Listen for Xdebug"
   (list :type "php"
         :request "launch"
         :name "PHP: Listen for Xdebug"
         :port 9003
         :pathMappings (let ((m (make-hash-table :test 'equal)))
                          (puthash "/var/www/html" (expand-file-name "~/projects/mysite") m)
                          m)))

  :bind (:map dap-mode-map
              (""  . dap-debug)
              (""  . dap-next)
              (""  . dap-step-in)
              (""  . dap-step-out)
              (""  . dap-breakpoint-toggle)
              ("C-c d" . dap-hydra)))

The :pathMappings key maps the container path (left) to the host path (right). Adjust this to match your Docker volume mount.

Starting a Debug Session

  1. Set a breakpoint with F9 on any PHP line
  2. Launch the template with F5 → "PHP: Listen for Xdebug" — Emacs now listens on port 9003
  3. Load the page in your browser. Execution halts at the breakpoint
  4. Step through with F6 (next), F7 (step in), F8 (step out)
  5. Inspect locals in the *dap-ui-locals* buffer

Useful Complementary Packages

Flymake for Inline Diagnostics

Eglot uses Flymake by default. Ensure it runs PHP syntax checks as a secondary backend:

(use-package flymake-phpstan
  :ensure t
  :hook (php-mode . flymake-phpstan-turn-on)
  :hook (php-ts-mode . flymake-phpstan-turn-on))

Yasnippet for PHP Snippets

(use-package yasnippet
  :ensure t
  :hook ((php-mode php-ts-mode) . yas-minor-mode))

(use-package yasnippet-snippets
  :ensure t)

Projectile for Drupal Projects

(use-package projectile
  :ensure t
  :config
  (projectile-mode +1)
  ;; Drupal projects have composer.json and web/ directory
  (add-to-list 'projectile-project-root-files "composer.json")
  :bind-keymap ("C-c p" . projectile-command-map))

Drush Integration

Run Drush commands without leaving Emacs using compile or async-shell-command:

(defun drush-cache-rebuild ()
  "Run drush cache:rebuild from the Drupal project root."
  (interactive)
  (let ((default-directory (projectile-project-root)))
    (compile "vendor/bin/drush cache:rebuild")))

(global-set-key (kbd "C-c d r") #'drush-cache-rebuild)

Complete init.el Snippet

A minimal, self-contained PHP development setup:

;; PHP development stack for Emacs 29+
(use-package php-mode
  :ensure t
  :mode ("\\.php\\'" "\\.module\\'" "\\.inc\\'" "\\.theme\\'")
  :hook ((php-mode . eglot-ensure)
         (php-mode . yas-minor-mode)
         (php-mode . flymake-mode)))

(use-package eglot
  :ensure nil
  :config
  (setq-default eglot-workspace-configuration
                '((:intelephense
                   :environment (:phpVersion "8.2")
                   :format (:enable t))))
  (setq eglot-connect-timeout 60
        eglot-events-buffer-size 0))

(use-package dap-mode
  :ensure t
  :config
  (require 'dap-php)
  (dap-mode 1)
  (dap-ui-mode 1))

(use-package flymake-phpstan
  :ensure t
  :hook (php-mode . flymake-phpstan-turn-on))

Summary

  • Install tree-sitter-php grammar with treesit-install-language-grammar
  • Use Eglot with Intelephense (npm install -g intelephense) or phpactor for open-source LSP
  • Configure eglot-workspace-configuration to set the target PHP version
  • Wire PHP CS Fixer via reformatter for automatic on-save formatting
  • Install Xdebug 3 and set XDEBUG_MODE=debug — use env vars in Docker to avoid rebuilds
  • Install @xdebug/vscode-php-debug and configure DAP Mode with a :pathMappings entry per project
  • Set breakpoints with F9, start listening with F5, step with F6–F8
  • Add flymake-phpstan for inline static analysis warnings alongside LSP diagnostics
Tags

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.
Please share this article on your favorite website or platform.