Pitch: phpm: um "pnpm para PHP" escrito em Rust, como camada sobre o Composer
Todo computador de quem trabalha com PHP acumula a mesma coisa: dezenas de pastas vendor/, cada uma com sua cópia completa das mesmas dependências. Cinco projetos Laravel são cinco cópias inteiras do illuminate/*, do symfony/*, do monolog/monolog. No JavaScript o pnpm resolveu isso com um store global e links; no PHP a gente continua duplicando.
Resolvi atacar esse problema específico e escrevi o phpm: um gerenciador de dependências PHP, em Rust, que mantém uma única cópia de cada versão de pacote no disco e compartilha entre todos os projetos.
Repositório: https://github.com/lemesdaniel/phpm
A ideia central
O phpm não reimplementa o Composer. Reimplementar o resolvedor de dependências seria reescrever anos de regras de versão, e divergir dele em qualquer caso de borda quebraria a confiança. Além disso seria um trabalho enorme para algo que pode não ter adesão. Então a divisão de responsabilidade é explícita:
- O Composer resolve o grafo. O phpm chama
composer update --no-install --no-scripts --no-plugins --no-interaction. Isso recalcula e reescreve ocomposer.lock, mas o Composer nunca toca novendor/. - O phpm é dono do
vendor/. Ele lê o lock, baixa cada pacote para um store global em~/.phpm/storee materializa ovendor/do projeto.
A materialização é o ponto. Cada arquivo do vendor/ é um hard link para o arquivo correspondente no store. Como hard links compartilham o mesmo inode, a versão 3.8.1 do monolog/monolog existe uma vez no disco, não importa em quantos projetos apareça.
Por que hard link por arquivo, e não symlink de diretório
A escolha óbvia seria fazer vendor/monolog/monolog ser um symlink para a pasta no store. É mais simples e é parecido com o que o pnpm faz com o node_modules.
O problema é o realpath() do PHP. Várias bibliotecas resolvem caminhos relativos ao próprio arquivo (__DIR__, dirname(__FILE__)), e com symlink de diretório o realpath() retorna o caminho dentro do store, não dentro do projeto. Isso provavelmente iria crashear includes, publicação de assets, descoberta de configs.
Hard link por arquivo evita isso: para o PHP, o arquivo está fisicamente em vendor/..., com o caminho que ele espera. O custo é que diretórios não são deduplicados (só arquivos), e que hard link não cruza sistemas de arquivos. Para o caso de uso (vários projetos no mesmo disco) é o equilíbrio certo.
Garantias do store
O store é tratado como imutável e concorrente:
- Somente leitura: depois de gravado, cada pacote no store perde o bit de escrita (
chmod a-w). Se uma dependência tentar escrever dentro do própriovendor/(raro, mas existe), ela falha alto em vez de corromper a cópia compartilhada de todo mundo. - Escrita atômica: o download vai para um arquivo temporário,
fsync, e só entãorename. Nunca existe um pacote "pela metade" visível no store. - Lock por pacote:
flockcom modos compartilhado e exclusivo, para doisphpm installem paralelo não brigarem pelo mesmo pacote.
Um sentinela em vendor/.phpm-state torna o sync idempotente: se o lock e o modo (com ou sem dev) não mudaram, não há trabalho a refazer.
Compatibilidade com o Composer
Para o PHP da aplicação, o vendor/ precisa ser indistinguível de um gerado pelo Composer. O phpm gera os mesmos artefatos: autoload.php, autoload_psr4.php, autoload_classmap.php, installed.json, installed.php e os proxies em vendor/bin. O ClassLoader.php e o InstalledVersions.php são os arquivos originais do composer/composer, embarcados sem alteração.
O objetivo é compatibilidade funcional, não saída idêntica byte a byte: o Composer embute um hash aleatório por projeto nos nomes de classe do autoload, e replicar isso não traz benefício.
Um detalhe que deu trabalho: plugins de evento. Um projeto meu real quebrou com
Plugin dealerdirect/phpcodesniffer-composer-installer is missing a require statement for composer-plugin-api
A causa era o installed.json enxuto que eu gerava: sem o bloco require de cada pacote, o PluginManager do Composer não conseguia validar o plugin. A correção foi gerar um installed.json que espelha o composer.json de cada pacote (require, autoload, type, extra), e aí deixar o Composer ativar normalmente os plugins de evento que o projeto autoriza em config.allow-plugins.
Resultado real
Rodei em quatro projetos de produção (Laravel e APIs). Somando os quatro:
vendor/duplicado de forma ingênua (o que o Composer faria): 502 MB- No disco com phpm: 422 MB (store ocupando 416 MB)
- Economia: 16%, e a curva sobe conforme os projetos compartilham mais dependências
O dado que mais gostei: ao adicionar o quarto projeto, cujo vendor/ tem 96 MB, o store cresceu apenas 49 MB. Quase metade já existia, vinda dos outros projetos. Dá para confirmar olhando o nlinks do inode e o find -samefile.
Validado bootando frameworks: Laravel 13 (artisan sobe, package discovery roda), Symfony 8.1 (bin/console) e PHPUnit (vendor/bin/phpunit).
Limitações encontradas (v1)
Não quero vender o que ele não é:
- Precisa de PHP e Composer instalados. A v1 não tem resolvedor próprio; ela orquestra o Composer.
post-install-cmdepost-update-cmdnão rodam, sópost-autoload-dump. Em projeto laravel novo, rodephp artisan key:generateestorage:linkuma vez na mão.- Repositórios
path(pacotes locais) ainda não são suportados. - Plugins instaladores que mudam o caminho de instalação não funcionam, porque é o phpm, não o Composer, que monta o
vendor/. Plugins de evento funcionam. - Tratamento de retry em falha de rede transitória ainda está no backlog.
Testando
curl -LsSf https://raw.githubusercontent.com/lemesdaniel/phpm/main/install.sh | sh
Depois é só
cd seu-projeto-php
phpm install
É uma substituição direta do passo de install: o composer.json e o composer.lock continuam sendo a fonte da verdade, e você pode voltar pro composer quando quiser.
O projeto é open source (MIT) e escrito como um workspace Rust de oito crates, cada uma com uma responsabilidade (lockfile, store, acquire, linker, compat com o Composer, bridge, gc, cli).
Críticas e issues são bem-vindas, principalmente de quem tiver um composer.json que quebre algum caso de borda que eu não previ.