PHP Language Analyzer

Contract ID: CONTRACT-SCANNER-PHP-ANALYZER-013 Status: Published  ·  Version: 1.0.0  ·  Published: 2025-12-05 Owners: PHP Analyzer Guild, Scanner Guild

Reconciliation note (2026-05-30): This contract was originally drafted as a bootstrap design. The PHP analyzer has since shipped, and several sections described aspirational shapes that differ from the implementation. The document has been reconciled against the source at src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/. Sections that describe behaviour not yet present in source are marked (Roadmap — not yet implemented). The C# type signatures below mirror the actual internal types unless explicitly flagged as illustrative.

Overview

This contract defines the Stella Ops PHP language analyzer — the Scanner component that inventories a PHP project’s dependencies and attack surface. It covers Composer manifest/lock parsing, virtual file system (VFS) normalization, framework/CMS fingerprinting, runtime capability and PHAR/FFI scanning, and the language-component records the analyzer emits for deterministic, offline PHP project analysis.

Audience: engineers maintaining or extending the PHP analyzer, and Scanner contributors consuming its language-component output downstream (emit, SBOM serialization, reachability).

The implemented entry point is PhpLanguageAnalyzer (analyzer id php, display name PHP Analyzer), which implements IScannerLanguageAnalyzer and is activated through the PhpScannerAnalyzerPlugin plug-in (IScannerLanguageAnalyzerPlugin).

Scope

The PHP analyzer:

  1. Parses composer.json (PhpComposerManifestReader) and composer.lock (ComposerLockReader)
  2. Builds an in-memory virtual file system from the scan root, vendor directory, and configs (PhpInputNormalizer)
  3. Detects framework/CMS fingerprints (Laravel, Symfony, WordPress, Drupal, and 13 others) via PhpFrameworkFingerprinter
  4. Emits language-component records with composer PURLs plus dotted metadata keys
  5. Scans for autoload edges, include/require graphs, runtime capabilities, PHAR archives, FFI usage, framework surface, PHP config/extensions, and version conflicts

Note on output format: The analyzer emits Stella Ops language-component records (consumed by the Scanner emit pipeline), not CycloneDX SBOM documents directly. See Output Schema. CycloneDX/SPDX serialization is a downstream concern of StellaOps.Scanner.Emit.


Input Normalization

PhpInputNormalizer.NormalizeAsync(rootPath, ct) builds a PhpProjectInput from the scan root. It does not merge fixed mount points such as /app, /vendor, or /etc/php*; it recursively enumerates the supplied rootPath.

File Discovery (implemented)

The normalizer enumerates the root tree and classifies files by source into a PhpVirtualFileSystem. There is no IPhpSourceDiscovery interface or PhpDiscoveryOptions record in source. Discovery behaviour is fixed:

Each collected file is tagged with a PhpFileSource (see File Sources).

(Roadmap — not yet implemented) A configurable discovery surface (include/exclude globs, optional vendor/test inclusion) is not present. The previously documented IPhpSourceDiscovery / PhpDiscoveryOptions API and the default exclude patterns (*.min.php, cache/*) do not exist in source.

File Sources

internal enum PhpFileSource
{
    SourceTree,        // application code
    Vendor,            // composer vendor directory
    ContainerLayer,    // OCI/Docker layer (enum value present; not yet populated)
    PhpConfig,         // php.ini, conf.d
    WebServerConfig,   // .htaccess
    FpmConfig,         // php-fpm.conf / pool configs
    ComposerManifest   // composer.json / composer.lock
}

Composer Schema

composer.json Parsing

PhpComposerManifestReader.LoadAsync parses composer.json into a PhpComposerManifest with: Name, Description, Type, Version, License, Authors, Require, RequireDev, Autoload, AutoloadDev, Scripts, Bin, MinimumStability, and Sha256 (SHA-256 of the manifest file). Computed views include RequiredPhpVersion (the php constraint in require) and RequiredExtensions (the ext-* keys).

{
  "name": "vendor/package",
  "version": "1.2.3",
  "type": "library|project|metapackage|composer-plugin",
  "require": {
    "php": ">=8.1",
    "vendor/dependency": "^2.0"
  },
  "require-dev": { },
  "autoload": {
    "psr-4": { "App\\": "src/" },
    "classmap": ["database/"],
    "files": ["helpers.php"]
  }
}

composer.lock Parsing

ComposerLockReader extracts the lockfile-level content-hash and plugin-api-version, plus per-package metadata into ComposerPackage records. The lock data carries a LockSha256 (SHA-256 of the composer.lock file itself, lowercase hex), used as the package evidence hash.

internal sealed record ComposerPackage(
    string Name,
    string Version,
    string? Type,            // library, composer-plugin, etc.
    bool IsDev,              // from "packages-dev"
    string? SourceType,      // source.type: git|hg|svn
    string? SourceReference, // source.reference (commit/tag)
    string? DistSha256,      // dist.shasum (falls back to dist.checksum)
    string? DistUrl,         // dist.url
    ComposerAutoloadData Autoload);

Field reconciliation: The lockfile content-hash is a composer integrity hash over the root requirements, not a SHA-256 of package contents. The per-package dist.shasum (exposed as DistSha256) is the package archive checksum. The previously documented ComposerLockPackage record (with a single ContentHash = "SHA256 of package contents") did not match either field and has been replaced with the actual ComposerPackage shape above.

ComposerLockData holds: LockPath, ContentHash, PluginApiVersion, Packages, DevPackages, LockSha256.

Autoload Data

internal sealed class ComposerAutoloadData
{
    IReadOnlyList<string> Psr4;     // entries serialized as "Namespace\->path"
    IReadOnlyList<string> Classmap;
    IReadOnlyList<string> Files;
}

Note: PSR-0 is not parsed by the lock reader (only psr-4, classmap, files). The autoload graph builder (PhpAutoloadGraphBuilder) does emit a autoload.psr0_count metadata key, sourced separately from installed.json.

PURL Format

PhpPackage.Purl produces:

pkg:composer/vendor/package@version
pkg:composer/laravel/framework@10.48.7
pkg:composer/symfony/http-kernel@6.3.0

The component key for a package is purl::pkg:composer/<vendor>/<package>@<version>.


Virtual File System

Model (implemented)

The VFS is the internal type PhpVirtualFileSystem, built via a Builder. It is not a public record and does not carry a ContentHash of its own.

internal sealed class PhpVirtualFileSystem
{
    IReadOnlyList<PhpVirtualFile> Files { get; }   // deterministically ordered
    int Count { get; }
    bool TryGetFile(string relativePath, out PhpVirtualFile? file);
    IEnumerable<PhpVirtualFile> GetFilesBySource(PhpFileSource source);
    IEnumerable<PhpVirtualFile> GetFilesByPattern(string pattern); // simple glob
    IEnumerable<PhpVirtualFile> GetPhpFiles();
}

internal sealed record PhpVirtualFile
{
    string RelativePath { get; }   // normalized to forward slashes
    string AbsolutePath { get; }
    PhpFileSource Source { get; }
    string? Sha256 { get; init; }  // optional; not populated during normalization
    long? Size { get; init; }      // optional
    IReadOnlyDictionary<string, string>? Metadata { get; init; }
}

Reconciliation: The previously documented public PhpVirtualFileSystem record (with RootPath, Entries, PhpConfig, Composer, BLAKE3 ContentHash), the VfsEntry record, and the VfsEntryType enum do not exist in source. There is no BLAKE3 hashing; per-file hashing uses SHA-256 and is optional. The project-level container is PhpProjectInput (RootPath, FileSystem, Config, Framework, ComposerLock, ComposerManifest).

Deterministic Ordering

PhpVirtualFileSystem.Builder.Build() sorts files by RelativePath using StringComparer.Ordinal (case-sensitive, lexicographic). Duplicate relative paths are de-duplicated case-insensitively, with the last AddFile winning.


Framework Detection

Fingerprint Rules (implemented)

PhpFrameworkFingerprinter ships 17 framework/CMS definitions. Each definition scores by the fraction of its file markers that exist on disk (with a small boost when 3+ markers match); the highest-scoring candidate wins. When no on-disk match is found, DetectFromPackages falls back to composer package markers (fixed confidence 0.95). Confidence is a float in [0.0, 1.0], not a High/Medium/Low enum — there is no ConfidenceLevel type.

FrameworkSelected file markers (any-of, scored)Composer marker
Laravelartisan, bootstrap/app.php, config/app.php, routes/web.php, app/Http/Kernel.phplaravel/framework
Symfonybin/console, config/bundles.php, src/Kernel.php, symfony.locksymfony/framework-bundle
WordPresswp-config.php, wp-includes/version.php, wp-admin/admin.php, wp-content/themes, wp-content/plugins
Drupalcore/lib/Drupal.php, sites/default/settings.php, core/modules, modules/contribdrupal/core
Joomlaconfiguration.php, libraries/src/Version.php, administrator/index.php, components
Magentoapp/Mage.php, app/etc/config.php, app/etc/env.php, pub/index.phpmagento/framework
CodeIgnitersystem/CodeIgniter.php, app/Config/App.php, sparkcodeigniter4/framework
CakePHPbin/cake, config/app.php, src/Application.phpcakephp/cakephp
Slimpublic/index.phpslim/slim
Laminasconfig/application.config.php, module/Applicationlaminas/laminas-mvc
Yiiyii, config/web.php, controllers, viewsyiisoft/yii2
MediaWikiLocalSettings.php, includes/DefaultSettings.php, skins/Vector
phpBBconfig.php, phpbb/di/container_builder.php, styles/prosilver
Craftcraft, config/general.php, templatescraftcms/cms
TYPO3typo3/sysext, typo3conf/LocalConfiguration.phptypo3/cms-core
Octoberartisan, modules/system, pluginsoctober/rain
PrestaShopconfig/settings.inc.php, classes/PrestaShopAutoload.php, modules, themes

Version extraction (when a VersionFile + regex is defined) reads a marker file such as wp-includes/version.php ($wp_version) or vendor/.../Application.php (const VERSION).

Reconciliation of prior table: The earlier table claimed “Magento detected via Magento\ namespace” and “CodeIgniter via system/core/CodeIgniter.php” — the implementation uses file-path markers only (app/etc/env.php, system/CodeIgniter.php). Confidence was previously typed as High/Medium; it is a numeric score in source.

Fingerprint Output (implemented)

internal sealed class PhpFrameworkFingerprint
{
    PhpFrameworkKind Kind { get; }        // enum: None, Laravel, ... Custom
    string Name { get; }                  // "laravel", "symfony", ... ("unknown" when none)
    string? Version { get; }
    float Confidence { get; }             // 0.0 .. 1.0
    IReadOnlyList<string> Evidence { get; } // contributing markers, ordinal-sorted
    bool IsDetected { get; }              // Kind != None
}

Reconciliation: The prior PhpFrameworkFingerprint record used ConfidenceLevel Confidence and IndicatorFiles; the real type uses a float Confidence, an Evidence list, and adds Kind/IsDetected.

Framework signals surface in metadata as php.framework.kind, php.framework.name, php.framework.version, php.framework.confidence (formatted F2), and php.framework.evidence (;-joined), plus the project-level php.project.framework / php.project.framework_version.


PHP Configuration

Config File Discovery (implemented)

PhpConfigCollector.CollectAsync probes (relative to the scan root):

php.ini, php/php.ini, etc/php.ini, etc/php/php.ini, usr/local/etc/php/php.ini
conf.d/, php/conf.d/, etc/php/conf.d/, etc/php.d/, usr/local/etc/php/conf.d/   (*.ini)
.htaccess
php-fpm.conf, php-fpm.d/, etc/php-fpm.conf, etc/php-fpm.d/,
  etc/php/fpm/php-fpm.conf, etc/php/fpm/pool.d/, usr/local/etc/php-fpm.conf, usr/local/etc/php-fpm.d/

Reconciliation: The prior absolute-path list (/etc/php/*/php.ini, /etc/php/*/conf.d/*.ini, /etc/php-fpm.d/*.conf, /usr/local/etc/php/php.ini) used a different layout; the collector resolves paths relative to the scan root, not absolute container paths.

Security-Relevant Settings (implemented)

Settings are surfaced by PhpExtensionScanner into PhpEnvironmentSettings, which groups them into PhpSecuritySettings, PhpUploadSettings, PhpSessionSettings, PhpErrorSettings, and PhpResourceLimits. There is no single PhpSecurityConfig record.

internal sealed record PhpSecuritySettings(
    IReadOnlyList<string> DisabledFunctions,
    IReadOnlyList<string> DisabledClasses,
    bool OpenBasedir,
    string? OpenBasedirValue,
    bool AllowUrlFopen,
    bool AllowUrlInclude,
    bool ExposePhp,
    bool RegisterGlobals);

display_errors / display_startup_errors / log_errors live in PhpErrorSettings (emitted as error.display_errors, etc.). Security metadata keys include security.allow_url_fopen, security.allow_url_include, security.open_basedir, security.expose_php, security.disabled_functions[_count], and security.disabled_classes_count.

Reconciliation: The prior PhpSecurityConfig record (with OpenBasedir/DisableFunctions/DisableClasses/DisplayErrors as a single bag) does not exist. Disable lists are DisabledFunctions/DisabledClasses on PhpSecuritySettings; DisplayErrors is on PhpErrorSettings.


Output Schema

The analyzer writes language-component records through a ScannerComponentSinkWriter. Each record carries analyzerId (php), componentKey, optional purl, name, version, type, usedByEntrypoint, a flat metadata string→string map (dotted keys), and an evidence array.

Package Component (actual emitted shape)

{
  "analyzerId": "php",
  "componentKey": "purl::pkg:composer/laravel/framework@10.48.7",
  "purl": "pkg:composer/laravel/framework@10.48.7",
  "name": "laravel/framework",
  "version": "10.48.7",
  "type": "composer",
  "usedByEntrypoint": false,
  "metadata": {
    "composer.dev": "false",
    "composer.type": "library",
    "composer.source.type": "git",
    "composer.source.ref": "0123...4567",
    "composer.autoload.psr4": "Illuminate\\->src/Illuminate;...",
    "composer.autoload.files": "src/Illuminate/Foundation/helpers.php",
    "composer.dist.sha256": "6f1b...",
    "composer.dist.url": "https://api.github.com/repos/laravel/framework/zipball/...",
    "composer.plugin_api_version": "2.6.0",
    "composer.content_hash": "e01f9b7d...",
    "php.capability.framework": "laravel"
  },
  "evidence": [
    {
      "kind": "file",
      "source": "composer.lock",
      "locator": "composer.lock",
      "value": "laravel/framework@10.48.7",
      "sha256": "885d825c..."
    }
  ]
}

Reconciliation: The earlier “SBOM Component” example used CycloneDX shapes (bom-ref, properties[] with stellaops:php:* names, and an evidence.identity block with methods/technique). The analyzer does not emit CycloneDX directly, does not use stellaops:php:* property names (it uses dotted keys like composer.* and php.*), and uses usedByEntrypoint + a flat evidence array (kind/source/locator/value/sha256). CycloneDX/SPDX serialization happens later in StellaOps.Scanner.Emit.

Component Types Emitted

In addition to per-package composer components, PhpLanguageAnalyzer emits:

TypeComponent keyPurpose
composerpurl::pkg:composer/<v>/<p>@<ver>One per locked package (packages + packages-dev)
php-projectphp::project-summaryAggregated project metadata (autoload/include/capability/phar/surface/env/ffi/conflict summaries)
php-binphp::bin::<name>Composer bin entrypoints
php-pluginphp::plugin::<package>Composer plugins (classes implementing the plugin API)
php-pharphp::phar::<relativePath>Discovered PHAR archives

Project-Summary Metadata Keys (implemented)

The php-project summary aggregates dotted-key metadata across the analysis passes. Observed key prefixes (from golden fixtures):

(Completeness gap closed) The capability/PHAR/FFI/surface/include/conflict scanning passes (PhpCapabilityScanner, PhpPharScanner, PhpFfiDetector, PhpFrameworkSurfaceScanner, PhpIncludeGraphBuilder, PhpVersionConflictDetector, PhpExtensionScanner) were absent from the original contract. They are a substantial part of the shipped analyzer output.

Analysis Store Keys

(Roadmap — not implemented) There is no PhpAnalysisKeys static class in source. The analyzer does not persist named analysis-store entries; it emits language-component records (above) via the component sink. The previously documented constants (php.vfs, php.composer, php.frameworks, php.security-config, php.autoload) do not exist.


Offline Kit Target

(Roadmap — not yet implemented) No offline-kit bundle, Packagist mirror, or offline composer repository exists in source. There is no IOfflineComposerRepository interface or ComposerPackage/ComposerManifest offline-resolution API. The analyzer is already offline-friendly because it reads only local composer.lock / composer.json / vendor/ content and never contacts a remote registry, but the bundle layout below is aspirational.

offline/php-analyzer/                  # PLANNED — not produced today
├── manifests/
│   └── php-analyzer-manifest.json
├── fixtures/
│   ├── laravel-app/
│   ├── symfony-app/
│   └── wordpress-site/
├── vendor-cache/
│   └── packages.json
└── SHA256SUMS

Test Fixtures

Actual Fixtures (implemented)

Golden fixtures live under src/Scanner/__Tests/StellaOps.Scanner.Analyzers.Lang.Php.Tests/Fixtures/lang/php/. Each fixture directory contains a composer.lock and a single expected.json (the full ordered array of emitted language-component records). The fixtures are:

FixtureCoverage
basic/Minimal composer project (one runtime + one dev package)
container/Container-layer style layout
laravel-extended/Laravel framework signals
legacy/Older/legacy PHP project
phar/PHAR archive discovery
symfony/Symfony framework signals
wordpress/WordPress CMS signals

Reconciliation: The prior fixture list (laravel-10-app/, symfony-6-app/, wordpress-6-site/, drupal-10-site/, composer-only/, legacy-php56/) and the per-fixture EXPECTED.sbom.json / EXPECTED.vfs.json / EXPECTED.meta.json golden split do not match the source. Actual fixtures use a single expected.json golden plus composer.lock. No Drupal or PHP-5.6-specific fixture exists today.

Golden Output Format (actual)

Fixtures/lang/php/<name>/
├── composer.lock
└── expected.json     # ordered array of emitted language-component records

Determinism is enforced by PhpLanguageAnalyzerTests comparing emitted records against expected.json.


Implementation Status

The analyzer is implemented (manifest org.stellaops.analyzer.status: production, plug-in version 0.1.0). The original phased plan is recorded below for history; all three phases have effectively shipped, with offline-kit bundling remaining as roadmap (see Offline Kit Target).

Phase 1: Core Parser — DONE

  1. PhpComposerManifestReader (composer.json) — implemented
  2. ComposerLockReader (composer.lock) — implemented
  3. PURL generation (PhpPackage.Purl) — implemented
  4. VFS construction (PhpInputNormalizer + PhpVirtualFileSystem) — implemented

Phase 2: Framework Detection — DONE

  1. Fingerprint rules (PhpFrameworkFingerprinter, 17 definitions) — implemented
  2. Float confidence scoring — implemented
  3. Version extraction via marker-file regex — implemented

Phase 3: Offline Support — PARTIAL

  1. Vendor/lockfile-only reads (offline by construction) — implemented
  2. Offline composer repository — not implemented (roadmap)
  3. Offline-kit bundle generation — not implemented (roadmap)

Project Location

The implemented analyzer lives under __Libraries, not the top-level path. The top-level src/Scanner/StellaOps.Scanner.Analyzers.Lang.Php/ directory currently contains only AGENTS.md (guild charter).

src/Scanner/__Libraries/StellaOps.Scanner.Analyzers.Lang.Php/
├── StellaOps.Scanner.Analyzers.Lang.Php.csproj
├── PhpLanguageAnalyzer.cs                 # IScannerLanguageAnalyzer (id "php")
├── PhpScannerAnalyzerPlugin.cs            # IScannerLanguageAnalyzerPlugin
├── PhpScannerSemanticEnricherPlugin.cs    # IScannerSemanticEnricherPlugin
├── GlobalUsings.cs
├── manifest.json                          # plug-in manifest
└── Internal/
    ├── PhpInputNormalizer.cs
    ├── PhpVirtualFileSystem.cs / PhpVirtualFile.cs
    ├── PhpProjectInput.cs
    ├── PhpComposerManifest.cs / PhpComposerManifestReader.cs
    ├── ComposerLockData.cs / ComposerLockReader.cs / ComposerPackage.cs
    ├── ComposerAutoloadData.cs / PhpAutoloadGraphBuilder.cs / PhpAutoloadEdge.cs
    ├── PhpFrameworkFingerprint.cs / PhpFrameworkFingerprinter.cs
    ├── PhpFrameworkSurface.cs / PhpFrameworkSurfaceScanner.cs
    ├── PhpCapability*.cs (Evidence/ScanBuilder/ScanResult/Scanner/Signals)
    ├── PhpPharArchive.cs / PhpPharScanner.cs
    ├── PhpFfiDetector.cs
    ├── PhpInclude*.cs (Edge/GraphBuilder/Scanner)
    ├── PhpConfig*.cs (Collection/Collector/Entry)
    ├── PhpExtension.cs / PhpExtensionScanner.cs
    ├── PhpInstalledJsonReader.cs
    ├── PhpPackage.cs / PhpPackageCollector.cs
    ├── PhpVersionConflictDetector.cs
    └── Runtime/

Reconciliation: The prior file list (ComposerJsonParser.cs, ComposerLockParser.cs, PhpVirtualFileSystem.cs at root, PhpFrameworkDetector.cs, PhpAnalyzerPlugin.cs, README.md) did not match source. Actual readers are PhpComposerManifestReader / ComposerLockReader; the fingerprinter is PhpFrameworkFingerprinter; the plug-in is PhpScannerAnalyzerPlugin; there is no README.md in the project.

Plug-in identity (implemented)

FieldValue
Manifest idstellaops.analyzer.lang.php
Plug-in (IScannerLanguageAnalyzerPlugin.Id)stellaops.scanner.plugin.php
Enricher plug-in idstellaops.scanner.plugin.php.enricher
Analyzer id (IScannerLanguageAnalyzer.Id)php
Version0.1.0
requiresRestarttrue
Capabilitieslanguage-analyzer, php, composer

Source defect flagged (not corrected here — doc-only change): manifest.json declares entryPoint.typeName = "StellaOps.Scanner.Analyzers.Lang.Php.PhpAnalyzerPlugin", but no PhpAnalyzerPlugin type exists; the actual plug-in type is PhpScannerAnalyzerPlugin. This is a source/manifest mismatch that the PHP Analyzer Guild should reconcile in code (it is outside the scope of this documentation edit).



Changelog

VersionDateAuthorChanges
1.0.02025-12-05PHP Analyzer GuildInitial contract
1.0.0 (reconciled)2026-05-30Docs reconciliationAligned C# signatures, framework table (17 frameworks, float confidence), composer lock/package shapes, VFS model, PHP config types, emitted component types and metadata keys, fixtures, and project location with shipped source; marked offline-kit, IPhpSourceDiscovery/PhpDiscoveryOptions, PhpAnalysisKeys, IOfflineComposerRepository, BLAKE3/VfsEntry/VfsEntryType, and CycloneDX-shaped output as not-implemented or roadmap; flagged the manifest.json typeName defect