Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
192 changes: 191 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,194 @@
PHP Store
=========

PHP Store allows to find and manage local PHP installations.
PHP Store discovers local PHP installations and selects the best installation
for a project. It supports PHP CLI, CGI, FPM, and FrankenPHP installations on
Linux, macOS, and Windows.

Installation
------------

Install the package with Go modules:

```console
go get github.com/symfony-cli/phpstore
```

Basic Usage
-----------

Create a store, then select PHP for a project directory:

```go
package main

import (
"log"
"os"
"path/filepath"

"github.com/symfony-cli/phpstore"
)

func main() {
configDir, err := os.UserConfigDir()
if err != nil {
log.Fatal(err)
}

projectDir, err := os.Getwd()
if err != nil {
log.Fatal(err)
}

cacheDir := filepath.Join(configDir, "my-app", "phpstore")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
log.Fatal(err)
}

store := phpstore.New(cacheDir, false, log.Printf)
selected, source, warning, err := store.BestVersionForDir(projectDir)
if err != nil {
log.Fatal(err)
}
if warning != "" {
log.Printf("warning: %s", warning)
}

log.Printf("using PHP %s from %s at %s", selected.Version, source, selected.PHPPath)
}
```

Pass `nil` instead of `log.Printf` to disable discovery logs.

Discovery and Caching
---------------------

`New()` discovers PHP installations in common platform-specific locations,
additional configured directories, and `PATH`. It uses `php-config` when
possible and falls back to running `php --version`.

The constructor arguments are:

- `configDir`: directory used for the `php_versions.json` discovery cache;
- `reload`: when `true`, removes the cache and performs a new discovery;
- `logger`: optional callback that receives formatted discovery messages.

Use `Versions()` to inspect the discovered installations. The result is sorted
by PHP version in ascending order:

```go
for _, version := range store.Versions() {
log.Printf(
"PHP %s: CLI=%s FPM=%s CGI=%s",
version.Version,
version.PHPPath,
version.FPMPath,
version.CGIPath,
)
}
```

`IsVersionAvailable()` accepts a major, minor, or patch version prefix:

```go
if store.IsVersionAvailable("8.4") {
log.Print("PHP 8.4 is available")
}
```

Version Selection
-----------------

`BestVersionForDir()` selects PHP using the first matching source in this
order:

1. The `SYMFONY_CLI_PHP_BINARY_PATH` override;
2. A `.php-version` file found from the requested directory upward;
3. `config.platform.php` in a `composer.json` file found from the requested
directory upward;
4. A `.php-version` file found from the current working directory upward;
5. The PHP type in `.symfony.cloud.yaml` found from the requested directory
upward;
6. The PHP type in `.platform.app.yaml` found from the requested directory
upward;
7. The first PHP installation found in `SYMFONY_CLI_PHP_PATH` or `PATH`;
8. The most recent discovered PHP installation.

A version constraint can select a major, minor, or patch release:

```text
8
8.4
8.4.6
```

When an exact patch release is unavailable, selection falls back to the most
recent patch release from the same minor version and returns a warning. It does
not fall back to another minor version.

PHP Flavors
-----------

A version constraint can include a server flavor:

```text
8.4-cli
8.4-cgi
8.4-fpm
8.4-frankenphp
```

The supported flavor constants are `FlavorCLI`, `FlavorCGI`, `FlavorFPM`, and
`FlavorFrankenPHP`. Use `SupportsFlavor()` to inspect support and
`ForceFlavor()` to select a supported flavor explicitly.

Without a flavor constraint, `ServerPath()` and `ServerTypeName()` use
FrankenPHP when applicable, then prefer FPM, CGI, and CLI in that order.

Environment Variables
---------------------

### `SYMFONY_CLI_PHP_PATH`

Adds directories to PHP discovery before the regular `PATH`. Use the operating
system path-list separator to provide several directories. This variable only
expands discovery; it does not select one installation when several match.

```console
export SYMFONY_CLI_PHP_PATH=/opt/php/8.3/bin:/opt/php/8.4/bin
```

On Windows PowerShell:

```powershell
$env:SYMFONY_CLI_PHP_PATH = 'C:\php83;C:\php84'
```

### `SYMFONY_CLI_PHP_BINARY_PATH`

Overrides automatic version selection with one specific PHP CLI binary. The
value must be an absolute path:

```console
export SYMFONY_CLI_PHP_BINARY_PATH=/usr/local/php8.4/bin/php
```

On Windows PowerShell:

```powershell
$env:SYMFONY_CLI_PHP_BINARY_PATH = 'C:\xampp\php\php.exe'
```

The store runs the selected binary with `--version`, resolves symlinks, and
detects companion FPM, CGI, `php-config`, `phpize`, and `phpdbg` binaries when
they follow a conventional installation layout. Standalone PHP binaries are
also supported. The selected installation is included in `Versions()`. A
successful override returns a warning to make the bypass of automatic selection
explicit.

License
-------

PHP Store is available under the GNU Affero General Public License version 3
or later. See [LICENSE](LICENSE) for details.
96 changes: 69 additions & 27 deletions discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -167,44 +167,75 @@ func (s *PHPStore) discoverPHP(dir, binName string) *Version {
func (s *PHPStore) discoverPHPViaPHP(dir, binName string) *Version {
php := filepath.Join(dir, "bin", binName)
if runtime.GOOS == "windows" {
binName += ".exe"
php = filepath.Join(dir, binName)
php = filepath.Join(dir, binName+".exe")
}

if _, err := os.Stat(php); err != nil {
version, err := s.inspectPHPBinary(php, dir, binName, true)
if err != nil {
s.log(" %s", err)
return nil
}

var buf bytes.Buffer
cmd := exec.Command(php, "--version")
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
s.log(` Unable to run "%s --version: %s"`, php, err)
return nil
return version
}

func (s *PHPStore) discoverPHPPath(php string) (*Version, error) {
if !filepath.IsAbs(php) {
return nil, errors.Errorf("php binary path must be absolute, got %q", php)
}

resolved, err := evalSymlinks(filepath.Clean(php))
if err != nil {
return nil, errors.Wrapf(err, "unable to resolve PHP binary %q", php)
}

dir := filepath.Dir(resolved)
binName := filepath.Base(resolved)
discoverCompanions := false
if runtime.GOOS == "windows" {
if extension := filepath.Ext(binName); strings.EqualFold(extension, ".exe") {
binName = binName[:len(binName)-len(extension)]
}
discoverCompanions = strings.Contains(binName, "php")
} else if filepath.Base(dir) == "bin" {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Info — Override of non-standard PHP layouts drops FPM/CGI server support.

On non-Windows, companion (FPM/CGI/config) discovery is enabled only when the resolved binary's parent directory is literally named bin (filepath.Base(dir) == "bin"). An override pointing at a custom-built PHP whose CLI binary is not under a bin/ directory (e.g. /opt/php8.4/php) is reported as a CLI-only installation with empty FPMPath/CGIPath even when a php-fpm/php-cgi sits alongside it, so the override silently loses FPM/CGI capability for such layouts.

dir = filepath.Dir(dir)
discoverCompanions = strings.Contains(binName, "php")
}

return s.inspectPHPBinary(php, dir, binName, discoverCompanions)
}

func (s *PHPStore) inspectPHPBinary(php, dir, binName string, discoverCompanions bool) (*Version, error) {
if _, err := os.Stat(php); err != nil {
return nil, errors.Wrapf(err, "unable to access PHP binary %q", php)
}

output, err := s.phpVersionOutput(php)
if err != nil {
return nil, errors.Wrapf(err, "unable to run %q", php+" --version")
}
r := regexp.MustCompile(`PHP (\d+\.\d+\.\d+)`)
data := r.FindSubmatch(buf.Bytes())
data := regexp.MustCompile(`PHP (\d+\.\d+\.\d+)`).FindSubmatch(output)
if data == nil {
s.log(" %s is not a PHP binary", php)
return nil
return nil, errors.Errorf("path %q is not a PHP binary", php)
}
php = filepath.Clean(php)
var err error
php, err = evalSymlinks(php)

resolved, err := evalSymlinks(filepath.Clean(php))
if err != nil {
s.log(" %s is not a valid symlink", php)
return nil
return nil, errors.Wrapf(err, "unable to resolve PHP binary %q", php)
}
v := s.validateVersion(dir, normalizeVersion(string(data[1])))
if v == nil {
return nil
return nil, errors.Errorf("unable to parse PHP version %q", data[1])
}
version := &Version{
Path: dir,
Version: v.String(),
FullVersion: v,
PHPPath: php,
PHPPath: resolved,
}
if !discoverCompanions {
s.log(" Found PHP: %s", version.PHPPath)
return version, nil
}

fpm := filepath.Join(dir, "sbin", strings.Replace(binName, "php", "php-fpm", 1))
Expand All @@ -217,14 +248,25 @@ func (s *PHPStore) discoverPHPViaPHP(dir, binName string) *Version {
phpize := filepath.Join(dir, "bin", strings.Replace(binName, "php", "phpize", 1))
phpdbg := filepath.Join(dir, "bin", strings.Replace(binName, "php", "phpdbg", 1))
if runtime.GOOS == "windows" {
fpm = filepath.Join(dir, strings.Replace(binName, "php", "php-fpm", 1))
cgi = filepath.Join(dir, strings.Replace(binName, "php", "php-cgi", 1))
phpconfig = filepath.Join(dir, strings.Replace(binName, "php", "php-config", 1))
phpize = filepath.Join(dir, strings.Replace(binName, "php", "phpize", 1))
phpdbg = filepath.Join(dir, strings.Replace(binName, "php", "phpdbg", 1))
fpm = filepath.Join(dir, strings.Replace(binName, "php", "php-fpm", 1)+".exe")
cgi = filepath.Join(dir, strings.Replace(binName, "php", "php-cgi", 1)+".exe")
phpconfig = filepath.Join(dir, strings.Replace(binName, "php", "php-config", 1)+".exe")
phpize = filepath.Join(dir, strings.Replace(binName, "php", "phpize", 1)+".exe")
phpdbg = filepath.Join(dir, strings.Replace(binName, "php", "phpdbg", 1)+".exe")
}
s.log(version.setServer(fpm, cgi, phpconfig, phpize, phpdbg))
return version

return version, nil
}

func runPHPVersion(php string) ([]byte, error) {
var buf bytes.Buffer
cmd := exec.Command(php, "--version")
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()

return buf.Bytes(), err
}

func (s *PHPStore) discoverPHPViaPHPConfig(dir, binName string) *Version {
Expand Down
Loading
Loading