Skip to content

PSR-4 自动加载

PSR-4(PHP Standard Recommendation 4)是 PHP-FIG 制定的自动加载标准,定义了从命名空间到文件路径的映射规则。它是现代 PHP 生态中最广泛使用的规范之一,Composer 的 PSR-4 自动加载功能就是基于此规范实现的。本节将详细讲解 PSR-4 的规范要求、映射规则、边界情况和最佳实践。

前置知识

阅读本节前,建议先了解:

基础概念

PSR-4 的核心思想

PSR-4 的核心规则非常简洁:一个完全限定类名(FQCN)具有如下形式

\NamespaceName\SubNamespaceNames\ClassName

命名空间前缀至少一个基准目录关联,基准目录下存放以命名空间为结构的 PHP 文件。

映射规则

FQCN: App\Http\Controllers\UserController
         └──────┘ └──────────────┘ └──────────┘
         前缀      子命名空间       类名

目录映射:
  App\ → src/
  
文件路径:
  src/Http/Controllers/UserController.php

详细说明

1. 规范定义

1.1 命名空间前缀

  • 必须至少有一个顶层命名空间(Vendor 名)
  • 每个命名空间前缀必须对应至少一个基准目录

1.2 类名

  • 类名必须与文件名完全一致(大小写敏感)
  • 类名必须.php 为后缀

1.3 映射规则

php
<?php
// 前缀:App\
// 基准目录:src/

// 类名              → 文件路径
App\Models\User src/Models/User.php
App\Http\Kernel src/Http/Kernel.php
App\Services\Email src/Services/Email.php

映射公式:

文件路径 = 基准目录 + 子命名空间部分(替换 \ 为 /) + 类名 + .php

2. Composer PSR-4 配置

json
{
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    }
}
json
{
    "autoload": {
        "psr-4": {
            "MyApp\\": "app/",
            "MyApp\\Tests\\": "tests/",
            "Database\\Migrations\\": "database/migrations/",
            "Database\\Seeders\\": "database/seeders/"
        }
    }
}

3. 多基准目录

一个命名空间前缀可以映射到多个目录:

json
{
    "autoload": {
        "psr-4": {
            "App\\": [
                "src/",
                "lib/"
            ]
        }
    }
}

Composer 会依次在 src/lib/ 中查找类文件。这在合并多个代码库或为第三方插件提供扩展点时非常有用。

4. 空命名空间前缀

json
{
    "autoload": {
        "psr-4": {
            "": "src/"
        }
    }
}

使用空字符串作为前缀意味着根命名空间直接映射到基准目录。不推荐使用此方式,因为它可能引发类名冲突。

5. 下划线不转换为目录

PSR-4 与 PSR-0 的一个关键区别是:PSR-4 不会将类名中的下划线转换为目录分隔符。

php
<?php
// PSR-4 规范:
// App\Models\User_Type → src/Models/User_Type.php(文件名保持不变)
// 而不是 src/Models/User/Type.php

// PSR-0 规范(已废弃):
// Vendor_Package_SomeClass → src/Vendor/Package/SomeClass.php

实战示例

场景一:标准 Laravel 项目结构

project-root/
├── app/
│   ├── Console/          → App\Console\
│   │   └── Kernel.php
│   ├── Exceptions/        → App\Exceptions\
│   │   └── Handler.php
│   ├── Http/
│   │   ├── Controllers/  → App\Http\Controllers\
│   │   │   └── UserController.php
│   │   ├── Middleware/    → App\Http\Middleware\
│   │   └── Requests/     → App\Http\Requests\
│   │       └── StoreUserRequest.php
│   ├── Models/           → App\Models\
│   │   └── User.php
│   ├── Providers/        → App\Providers\
│   │   └── AppServiceProvider.php
│   └── Services/         → App\Services\
│       └── UserService.php
├── database/
│   ├── migrations/       → Database\Migrations\
│   │   └── 2024_01_01_000000_create_users_table.php
│   └── seeders/          → Database\Seeders\
│       └── UserSeeder.php
├── tests/
│   ├── Feature/          → Tests\Feature\
│   │   └── UserTest.php
│   └── Unit/             → Tests\Unit\
│       └── UserServiceTest.php
└── composer.json
json
{
    "autoload": {
        "psr-4": {
            "App\\": "app/",
            "Database\\Factories\\": "database/factories/",
            "Database\\Seeders\\": "database/seeders/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/"
        }
    }
}

场景二:手动实现 PSR-4 加载器

php
<?php
declare(strict_types=1);

/**
 * 完整的 PSR-4 自动加载器实现
 */
class Psr4Autoloader
{
    /** @var array<string, array<int, string>> 命名空间前缀 → 基准目录映射 */
    private array $prefixes = [];

    /**
     * 注册命名空间前缀与基准目录
     */
    public function addNamespace(string $prefix, string $baseDir, bool $prepend = false): void
    {
        $prefix = trim($prefix, '\\') . '\\';
        $baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . '/';

        if (!isset($this->prefixes[$prefix])) {
            $this->prefixes[$prefix] = [];
        }

        if ($prepend) {
            array_unshift($this->prefixes[$prefix], $baseDir);
        } else {
            $this->prefixes[$prefix][] = $baseDir;
        }
    }

    /**
     * 注册到 spl_autoload_register
     */
    public function register(): void
    {
        spl_autoload_register([$this, 'loadClass']);
    }

    /**
     * PSR-4 自动加载函数
     */
    public function loadClass(string $class): void
    {
        $prefix = $class;

        while (false !== ($pos = strrpos($prefix, '\\'))) {
            $prefix = substr($class, 0, $pos + 1);
            $relativeClass = substr($class, $pos + 1);

            $mappedFile = $this->loadMappedFile($prefix, $relativeClass);
            if ($mappedFile !== null) {
                return;
            }

            $prefix = rtrim($prefix, '\\');
        }
    }

    /**
     * 加载映射文件
     */
    private function loadMappedFile(string $prefix, string $relativeClass): ?string
    {
        if (!isset($this->prefixes[$prefix])) {
            return null;
        }

        foreach ($this->prefixes[$prefix] as $baseDir) {
            $file = $baseDir . str_replace('\\', '/', $relativeClass) . '.php';

            if ($this->requireFile($file)) {
                return $file;
            }
        }

        return null;
    }

    /**
     * 安全加载文件
     */
    private function requireFile(string $file): bool
    {
        if (file_exists($file)) {
            require $file;
            return true;
        }
        return false;
    }
}

// 使用示例
$autoloader = new Psr4Autoloader();
$autoloader->addNamespace('App\\', __DIR__ . '/src/');
$autoloader->addNamespace('Tests\\', __DIR__ . '/tests/');
$autoloader->register();

场景三:PSR-4 迁移检查工具

php
<?php
declare(strict_types=1);

/**
 * PSR-4 合规性检查工具
 */
class Psr4Validator
{
    public function __construct(
        private readonly string $basePath,
        private readonly array $psr4Map
    ) {}

    /**
     * 扫描目录,检查所有 PHP 文件是否符合 PSR-4
     */
    public function validate(): array
    {
        $errors = [];

        foreach ($this->psr4Map as $prefix => $directories) {
            $dirs = is_array($directories) ? $directories : [$directories];

            foreach ($dirs as $dir) {
                $fullDir = $this->basePath . '/' . $dir;
                if (!is_dir($fullDir)) {
                    continue;
                }

                $this->scanDirectory($fullDir, $prefix, $dir, $errors);
            }
        }

        return $errors;
    }

    private function scanDirectory(
        string $dir,
        string $prefix,
        string $relativeBase,
        array &$errors
    ): void {
        $iterator = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS)
        );

        foreach ($iterator as $file) {
            if ($file->getExtension() !== 'php') {
                continue;
            }

            $filePath = $file->getPathname();
            $relativePath = substr($filePath, strlen($this->basePath . '/' . $relativeBase));

            // 期望的 FQCN
            $expectedFqcn = $prefix . str_replace(
                ['/', '.php'],
                ['\\', ''],
                $relativePath
            );

            // 从文件内容提取实际的 FQCN
            $actualFqcn = $this->extractFqcn($filePath);

            if ($actualFqcn === null) {
                continue; // 不是类文件
            }

            if ($actualFqcn !== $expectedFqcn) {
                $errors[] = sprintf(
                    '%s: FQCN 不匹配。期望 %s,实际 %s',
                    $relativePath,
                    $expectedFqcn,
                    $actualFqcn
                );
            }
        }
    }

    private function extractFqcn(string $file): ?string
    {
        $tokens = token_get_all(file_get_contents($file));
        $namespace = '';
        $className = '';

        for ($i = 0, $count = count($tokens); $i < $count; $i++) {
            if ($tokens[$i][0] === T_NAMESPACE) {
                $namespace = $this->extractNamespace($tokens, $i + 1);
            }
            if ($tokens[$i][0] === T_CLASS || $tokens[$i][0] === T_INTERFACE || $tokens[$i][0] === T_TRAIT || $tokens[$i][0] === T_ENUM) {
                $className = $this->extractName($tokens, $i + 1);
                break;
            }
        }

        if ($className === '') {
            return null;
        }

        return ($namespace ? $namespace . '\\' : '') . $className;
    }

    private function extractNamespace(array $tokens, int $start): string
    {
        $namespace = '';
        for ($i = $start, $count = count($tokens); $i < $count; $i++) {
            if ($tokens[$i][0] === T_NAME_QUALIFIED || $tokens[$i][0] === T_STRING) {
                $namespace .= $tokens[$i][1];
            } elseif ($tokens[$i] === ';') {
                break;
            }
        }
        return $namespace;
    }

    private function extractName(array $tokens, int $start): string
    {
        for ($i = $start, $count = count($tokens); $i < $count; $i++) {
            if ($tokens[$i][0] === T_STRING) {
                return $tokens[$i][1];
            }
        }
        return '';
    }
}

// 使用示例
$validator = new Psr4Validator(__DIR__, [
    'App\\' => 'src/',
    'Tests\\' => 'tests/',
]);

$errors = $validator->validate();
foreach ($errors as $error) {
    echo "PSR-4 违规: {$error}\n";
}

注意事项

1. 文件名大小写敏感

# ✅ 正确(Linux 和 Windows 都可以)
src/Models/User.php    → App\Models\User

# ⚠️ macOS/Windows 正常,Linux 失败
src/Models/user.php    → App\Models\User(文件名不匹配)

# 解决方案:保持文件名与类名完全一致

跨平台兼容性

在 macOS 和 Windows 上,文件系统默认不区分大小写,所以 User.phpuser.php 被视为同一文件。但在 Linux 上它们是不同的文件。确保在所有平台上保持文件名与类名大小写完全一致。

2. 不要在类文件中产生副作用

PSR-4 规范要求类文件只定义类,不应包含执行逻辑:

php
<?php
// ❌ 错误:类文件中有副作用
namespace App\Models;

class User
{
    // ...
}

User::boot(); // 副作用

// ✅ 正确:纯符号定义
namespace App\Models;

class User
{
    public static function boot(): void
    {
        // ...
    }
}

3. 一个文件一个类

PSR-4 没有严格要求一个文件只能有一个类,但强烈建议遵循此约定:

php
<?php
// ✅ 推荐:一个文件一个类
namespace App\Models;

class User
{
    // ...
}

// ❌ 不推荐:一个文件多个类
namespace App\Models;

class User
{
    // ...
}

class Admin extends User
{
    // ...
}

最佳实践

1. 选择有意义的命名空间前缀

php
<?php
// ✅ 推荐:清晰的命名空间
namespace MyCompany\ProjectName\Module;

// ❌ 不推荐:过于笼统
namespace App;
namespace Utils;

2. 保持目录结构简洁

# ✅ 推荐:清晰的分层
src/
├── Controllers/
├── Models/
├── Services/
└── Repositories/

# ❌ 不推荐:过深或过浅
src/App/Http/Web/Controllers/Admin/DashboardController.php
src/DashboardController.php

下一节

继续学习:其他 PSR 规范概览 — 了解 PSR-3、PSR-6、PSR-7、PSR-11 等其他重要规范。

参考链接