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 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 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 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 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 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