3

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 o composer.lock, mas o Composer nunca toca no vendor/.
  • O phpm é dono do vendor/. Ele lê o lock, baixa cada pacote para um store global em ~/.phpm/store e materializa o vendor/ 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.

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óprio vendor/ (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ão rename. Nunca existe um pacote "pela metade" visível no store.
  • Lock por pacote: flock com modos compartilhado e exclusivo, para dois phpm install em 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-cmd e post-update-cmd não rodam, só post-autoload-dump. Em projeto laravel novo, rode php artisan key:generate e storage:link uma 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.

https://github.com/lemesdaniel/phpm

Carregando publicação patrocinada...
1

Meus 2 cents,

Parabens pela iniciativa !

Tenho usado um container para cada projeto - entao nao se aplica, mas confesso que achei a ideia legal para quem tem muitos projetos no mesmo local.

Na lista para testar.

Repositorio devidamente starreado e forkeado - obrigado por compartilhar !

Saude e Sucesso !


Este post foi favoritado via extensão TABNEWS FAVORITOS

Tem curiosidade sobre IA ? Da uma olhada no meu LIVRO: IA PARA ENGENHEIROS