博客 / 詳情

返回

PHP 的問題不在語言本身,而在我們怎麼寫它

PHP 的問題不在語言本身,而在我們怎麼寫它

代碼庫爛了不是語言的鍋,是趕工和慣性。

PHP 的口碑,幾乎在每次技術討論中都會被拎出來。應用慢、亂、不安全、改起來痛苦?總有人聳聳肩説:"嗯……畢竟是 PHP 嘛。"

這話很少出於技術判斷,更像是一種習慣性甩鍋。

事實比這簡單,也更扎心:大多數 PHP 系統之所以難維護,是我們自己放任的結果。PHP 不會一上來就逼你做架構設計、劃邊界、守規矩。它很寬容,很務實,特別擅長讓你把一個“能跑就行”的東西趕出來。

但今天能跑的代碼庫,明天可能就是災難。

一個 PHP 項目淪為恐怖故事,很少是因為 PHP 做不到更好,而是團隊從來沒養成那些能讓項目越做越大還不崩的習慣——結構、測試、約定、關注點分離。

現代 PHP 完全有能力做到:

  • 嚴格類型(是的,真正的類型)
  • 整潔架構
  • 依賴注入
  • 表達力強的領域模型
  • 規範的錯誤處理
  • 可靠的測試
  • 高性能(OPcache/JIT、緩存、合理的 I/O)
  • 成熟的工具鏈

如果你對 PHP 的印象還停留在"到處 include 文件"和"在視圖裏寫 SQL",那你罵的不是 PHP 這門語言,而是一種早該被淘汰的 PHP 寫法。

這篇文章不是在給 PHP 洗地,只是想説清楚一件事:PHP 是一面鏡子,照出來的是你的工程文化。照出來不好看,換面鏡子也沒用。

PHP 很寬容——寬容的語言會放大你的習慣

有些語言生態從一開始就逼你把結構搭好。想做稍微複雜一點的東西,就繞不開包、模塊、接口、依賴注入這些概念,哪怕你沒主動要求,約束也自動就在那了。

PHP 的玩法不一樣:

  • 可以從一個文件起步
  • 可以毫無阻力地混合各層
  • 可以在任何地方訪問全局變量
  • 可以在控制器裏直接查數據庫
  • 可以忽略類型照樣上線

這種靈活性本身不是壞事,PHP 靠它當了多年 Web 開發的默認選擇。但它也埋了一個坑:結構顯得可有可無,而可有可無的東西在趕工時一定會被砍掉。

很多“PHP 太爛了”的故事,背後的真實劇情是“趕工期上了線,然後重構的債一直沒還”。

PHP 沒有造成這個問題,它只是沒有阻止。

"都怪 PHP"往往是在逃避責任

系統讓人痛苦的時候,甩鍋給語言最省事,因為語言最容易看到。真正的原因往往藏得更深:

  • 沒有統一的編碼規範
  • 沒有架構負責人
  • 沒有測試
  • 沒有為重構分配時間
  • 代碼評審時鬆時緊
  • "先交付再説"的激勵機制

這些問題哪個技術棧都有。區別在於 PHP 能讓你在幾乎沒有約束的情況下把項目推得很遠,技術債悄悄攢着——然後在某一天集中爆發。

PHP 成了替罪羊,因為承認流程爛了,比甩鍋給語言難多了。

現代 PHP 不是你記憶中的 PHP

如果你對 PHP 的認知還停在"PHP 5 加一堆隨意 include"的年代,那你錯過的東西太多了:

  • declare(strict_types=1);
  • 標量類型和返回類型
  • 類型化屬性
  • 聯合類型
  • 枚舉
  • 屬性註解(Attributes)
  • 更好的錯誤語義
  • Composer 成為標配
  • PSR 標準
  • 優秀的框架(Laravel、Symfony)和組件
  • 靜態分析工具(PHPStan/Psalm)
  • 代碼格式化工具(PHP-CS-Fixer)
  • 容器化 / CI 工作流

語言進化了,但很多團隊沒有。

所以真正的問題是:你寫 PHP 的時候,是把它當成一門現代後端語言,還是當成趕工時湊合用的腳本?

經典 PHP 反模式:"什麼都塞進控制器"

下面這套流程,在很多項目裏都能看到:

  1. 控制器接收請求
  2. 控制器做驗證
  3. 控制器拼查詢
  4. 控制器處理業務規則
  5. 控制器更新數據庫
  6. 控制器格式化響應
  7. 控制器觸發副作用(郵件、隊列)

能跑,能上線,功能還能往上堆。然後就開始變脆——因為控制器已經變成了一個攬了業務規則、數據持久化和 I/O 的上帝對象。

看一個簡化版的例子。

❌ 反模式:所有邏輯塞在控制器裏

<?php
class CheckoutController
{
    public function placeOrder(array $request): array
    {
        $userId = (int)($request['user_id'] ?? 0);
        $items  = $request['items'] ?? [];
        if ($userId <= 0 || empty($items)) {
            return ['ok' => false, 'error' => 'Invalid request'];
        }
        $pdo = new PDO($_ENV['DB_DSN'], $_ENV['DB_USER'], $_ENV['DB_PASS']);
        $pdo->beginTransaction();
        try {
            // Load user
            $stmt = $pdo->prepare("SELECT id, status FROM users WHERE id = ?");
            $stmt->execute([$userId]);
            $user = $stmt->fetch(PDO::FETCH_ASSOC);
            if (!$user || $user['status'] !== 'active') {
                throw new RuntimeException("User not active");
            }
            // Calculate total
            $total = 0;
            foreach ($items as $it) {
                $productId = (int)$it['product_id'];
                $qty       = (int)$it['qty'];
                $stmt = $pdo->prepare("SELECT id, price, stock FROM products WHERE id = ?");
                $stmt->execute([$productId]);
                $product = $stmt->fetch(PDO::FETCH_ASSOC);
                if (!$product) {
                    throw new RuntimeException("Product not found");
                }
                if ($qty <= 0 || $qty > (int)$product['stock']) {
                    throw new RuntimeException("Insufficient stock");
                }
                $total += ((int)$product['price']) * $qty;
                // Reduce stock inline
                $stmt = $pdo->prepare("UPDATE products SET stock = stock - ? WHERE id = ?");
                $stmt->execute([$qty, $productId]);
            }
            // Insert order
            $stmt = $pdo->prepare("INSERT INTO orders(user_id, total, created_at) VALUES(?, ?, NOW())");
            $stmt->execute([$userId, $total]);
            $orderId = (int)$pdo->lastInsertId();
            // Insert items
            $stmt = $pdo->prepare("INSERT INTO order_items(order_id, product_id, qty) VALUES(?, ?, ?)");
            foreach ($items as $it) {
                $stmt->execute([$orderId, (int)$it['product_id'], (int)$it['qty']]);
            }
            $pdo->commit();
            return ['ok' => true, 'order_id' => $orderId, 'total' => $total];
        } catch (Throwable $e) {
            $pdo->rollBack();
            return ['ok' => false, 'error' => $e->getMessage()];
        }
    }
}

這段代碼爛,不是因為它用 PHP 寫的,而是因為它把這些東西全攪在了一起:

  • 輸入驗證
  • 事務管理
  • 業務規則
  • 持久化
  • 狀態變更
  • 響應格式化

不連數據庫就沒法測業務邏輯,不復制代碼就沒法複用規則,改一個小地方都提心吊膽。

如果你平時見到的 PHP 都長這樣,有偏見很正常。但話説回來,PHP 沒逼你寫成這樣——我們自己選的這條路,圖的就是快。

"好的 PHP"長什麼樣:無聊的結構,清晰的邊界

寫得好的 PHP 代碼往往看起來"沒什麼技術含量"。這不是壞事——無聊的代碼就是可預測的代碼。

更合理的分層方式是:

  • 控制器只處理 HTTP 層(請求/響應)
  • 應用/服務層協調用例
  • 領域對象負責維護業務不變量
  • 倉儲層處理持久化
  • 副作用通過接口隔離

下面用更清晰的結構重寫同一個功能。

✅ 現代 PHP "用例"風格

下面的代碼儘量精簡——不綁定特定框架,但和 Laravel/Symfony 的寫法兼容。

Step A:定義請求 DTO

<?php
declare(strict_types=1);
final class PlaceOrderCommand
{
    /**
     * @param array<int, array{productId:int, qty:int}> $items
     */
    public function __construct(
        public readonly int $userId,
        public readonly array $items
    ) {}
}

Step B:定義領域異常(業務錯誤不應該是 500)

<?php
declare(strict_types=1);
class DomainException extends RuntimeException {}
final class UserNotActive extends DomainException {}
final class ProductNotFound extends DomainException {}
final class InsufficientStock extends DomainException {}
final class InvalidOrder extends DomainException {}

Step C:為依賴定義小接口

<?php
declare(strict_types=1);
interface UserRepository
{
    public function getStatus(int $userId): ?string;
}
final class ProductSnapshot
{
    public function __construct(
        public readonly int $id,
        public readonly int $price,
        public readonly int $stock
    ) {}
}
interface ProductRepository
{
    public function getSnapshot(int $productId): ?ProductSnapshot;
    public function decreaseStock(int $productId, int $qty): void;
}
final class OrderResult
{
    public function __construct(
        public readonly int $orderId,
        public readonly int $total
    ) {}
}
interface OrderRepository
{
    /**
     * @param array<int, array{productId:int, qty:int, price:int}> $lines
     */
    public function create(int $userId, int $total, array $lines): int;
}
interface TransactionManager
{
    /**
     * @template T
     * @param callable():T $fn
     * @return T
     */
    public function run(callable $fn): mixed;
}

Step D:實現用例(服務層)

<?php
declare(strict_types=1);
final class PlaceOrderHandler
{
    public function __construct(
        private readonly TransactionManager $tx,
        private readonly UserRepository $users,
        private readonly ProductRepository $products,
        private readonly OrderRepository $orders
    ) {}
    public function handle(PlaceOrderCommand $cmd): OrderResult
    {
        if ($cmd->userId <= 0 || $cmd->items === []) {
            throw new InvalidOrder("User and items are required.");
        }
        $status = $this->users->getStatus($cmd->userId);
        if ($status !== 'active') {
            throw new UserNotActive("User is not active.");
        }
        return $this->tx->run(function () use ($cmd): OrderResult {
            $lines = [];
            $total = 0;
            foreach ($cmd->items as $item) {
                $productId = $item['productId'];
                $qty       = $item['qty'];
                if ($qty <= 0) {
                    throw new InvalidOrder("Quantity must be > 0.");
                }
                $snapshot = $this->products->getSnapshot($productId);
                if (!$snapshot) {
                    throw new ProductNotFound("Product {$productId} not found.");
                }
                if ($qty > $snapshot->stock) {
                    throw new InsufficientStock("Insufficient stock for {$productId}.");
                }
                $lineTotal = $snapshot->price * $qty;
                $total += $lineTotal;
                // Reserve/update stock
                $this->products->decreaseStock($productId, $qty);
                $lines[] = [
                    'productId' => $productId,
                    'qty'       => $qty,
                    'price'     => $snapshot->price,
                ];
            }
            $orderId = $this->orders->create($cmd->userId, $total, $lines);
            return new OrderResult($orderId, $total);
        });
    }
}

Step E:控制器變得輕薄且可測試

<?php
declare(strict_types=1);
final class CheckoutController
{
    public function __construct(private readonly PlaceOrderHandler $handler) {}
    public function placeOrder(array $request): array
    {
        try {
            $itemsRaw = $request['items'] ?? [];
            $items = array_map(
                fn($it) => [
                    'productId' => (int)($it['product_id'] ?? 0),
                    'qty'       => (int)($it['qty'] ?? 0),
                ],
                is_array($itemsRaw) ? $itemsRaw : []
            );
            $cmd = new PlaceOrderCommand(
                userId: (int)($request['user_id'] ?? 0),
                items: $items
            );
            $result = $this->handler->handle($cmd);
            return [
                'ok' => true,
                'order_id' => $result->orderId,
                'total' => $result->total,
            ];
        } catch (DomainException $e) {
            return ['ok' => false, 'error' => $e->getMessage()];
        } catch (Throwable $e) {
            // avoid leaking internals
            return ['ok' => false, 'error' => 'Unexpected error'];
        }
    }
}

這個版本不是"為了複雜而複雜",而是把複雜度放到了該放的地方:

  • 業務規則集中管理
  • 事務受控
  • 控制器極簡
  • 依賴抽象化
  • 終於可以寫測試了

而且這些全是原生 PHP,沒用什麼黑魔法。

測試:停止甩鍋給語言的最快方式

很多 PHP 團隊不寫測試,因為早年寫起來確實彆扭。但現在的 PHP 寫測試已經很順手了。

下面用一個簡單的 PHPUnit 例子演示:通過 mock 倉儲測試業務邏輯,完全不需要數據庫。

✅ PHPUnit 風格的單元測試(無需數據庫)

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class PlaceOrderHandlerTest extends TestCase
{
    public function test_it_places_an_order_and_returns_total(): void
    {
        $tx = new class implements TransactionManager {
            public function run(callable $fn): mixed { return $fn(); }
        };
        $users = new class implements UserRepository {
            public function getStatus(int $userId): ?string { return 'active'; }
        };
        $products = new class implements ProductRepository {
            private array $stock = [10 => 5];
            public function getSnapshot(int $productId): ?ProductSnapshot {
                if ($productId !== 10) return null;
                return new ProductSnapshot(10, price: 200, stock: $this->stock[10]);
            }
            public function decreaseStock(int $productId, int $qty): void {
                $this->stock[$productId] -= $qty;
            }
        };
        $orders = new class implements OrderRepository {
            public function create(int $userId, int $total, array $lines): int { return 123; }
        };
        $handler = new PlaceOrderHandler($tx, $users, $products, $orders);
        $cmd = new PlaceOrderCommand(
            userId: 7,
            items: [
                ['productId' => 10, 'qty' => 2],
            ]
        );
        $result = $handler->handle($cmd);
        $this->assertSame(123, $result->orderId);
        $this->assertSame(400, $result->total);
    }
}

如果你的用例能這樣測試,"PHP 不可維護"這種話就很難再理直氣壯地説出口了。可維護性不是語言自帶的能力,是靠結構和測試撐起來的。

"框架神話":Laravel/Symfony 不會自動拯救你

框架有用,但它攔不住你寫出爛架構。比如在 Laravel 裏,你照樣可以:

  • 寫出臃腫的控制器
  • 把領域邏輯全塞進 Eloquent 模型
  • 因為覺得"繞了一層"而直接跳過服務層

如果你見過控制器寫了 800 行的 Laravel 項目,問題不在 Laravel,而在於團隊把框架當成了不做設計的理由。

框架是工具箱。它能蓋房子——也能堆一堆木頭。

為什麼 PHP 比其他技術棧更容易捱罵

原因説起來有點微妙:在 PHP 裏,糟糕的決策暴露得更快。

有些技術棧的抽象層能把爛代碼蓋住更長時間。但在 PHP 裏,寫得爛一眼就能看出來:

  • 職責混雜
  • 約定不一致
  • 複製粘貼的重複代碼
  • 隱蔽的全局變量
  • 隨意的錯誤處理

PHP 不會替你兜底,所以它最容易被拎出來當靶子。

但一門逼你守規矩的語言不見得"更好",它只是讓你更難偷懶。PHP 把門檻放得很低——所以你的團隊文化反而更重要。

"整潔的 PHP"很無聊——這是誇獎

寫得整潔的 PHP 代碼,通常看起來平平無奇:

  • 命名清晰
  • 函數短小
  • 輸入輸出明確
  • 錯誤處理可預測
  • 依賴注入
  • 儘量少的魔法

它不炫技,不追求巧妙。

無聊的代碼在凌晨兩點容易調試。無聊的代碼容易交給新同事。無聊的代碼在團隊擴張時能扛得住。

如果你希望 PHP 不再被當笑話,那就寫無聊的 PHP。

實操清單:如何停止寫"被人罵"的 PHP

如果你想寫出不被人嘲笑的 PHP 系統,下面是一份實用的底線清單:

  • 在新代碼中開啓嚴格類型declare(strict_types=1);
  • 所有依賴通過 Composer 和自動加載管理,不要手動 include。
  • 保持控制器輕薄(只處理 HTTP),業務規則放到 handler/service 中。
  • 領域規則和持久化分離,倉儲或查詢服務能保持數據訪問的一致性。
  • 使用顯式的 DTO 傳遞請求和命令,不要到處傳裸數組。
  • 區分領域錯誤和系統錯誤,不是每個異常都該是 500。
  • 在用例層添加單元測試,如果業務邏輯離了數據庫就沒法測,説明你的邊界劃錯了。
  • 使用靜態分析工具(PHPStan/Psalm)防止隱性迴歸。
  • 引入代碼風格工具並在 CI 中強制執行,一致性很重要。
  • 持續重構,如果重構變成了"一個項目",那它永遠不會發生。

以上這些不是 PHP 獨有的——恰恰相反,放到哪個語言都一樣。

結語:PHP 是一面鏡子——別砸鏡子

PHP 不完美,沒有語言是完美的。但用 PHP 遇到的大部分痛苦不是語言造成的,而是寫法造成的:趕工、職責混雜、邊界模糊、"以後再改"的文化。

一個 PHP 項目變得不可收拾的時候,很少是因為 PHP 撐不起好架構,而是從來沒人把架構當回事。

PHP 是一面鏡子:

  • 如果你有紀律,它看起來很專業。
  • 如果你很隨意,它看起來很混亂。
  • 如果你在持續的趕工壓力下沒有質量標準,它看起來就像戰場。

所以下次有人説"都怪 PHP"的時候,你可以反問一句:

是 PHP 的問題……還是我們流程的問題?

因為代碼不是語言自己寫的。

是你寫的。

PHP 的問題不在語言本身,而在我們怎麼寫它

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.