PHPでエラーと呼ばれているものまとめ

PHPでエラーと呼ばれているものをまとめてみました。

誰が見てもエラーとわかるもの

Error

<?php
try {
    $a = $b / $c;
} catch (DivisionByZeroError $e) {
    $a = 0;
}

PHP7から導入されたError
オブジェクトとして扱えて、try, catchできます。
便利ですね。

エラー

Fatal error: Call to undefined method Controller::index() in Controller.php on line 6

PHP5おなじみのこれ。
良い子は @error_reporting(0); を使ってはいけませんよ。

Fatal errorParse error のみをエラーと呼ぶ流派もいるそうです。

エラーか微妙なライン

エラー例外

<?php
function exception_error_handler($severity, $message, $file, $line) {
    throw new ErrorException($message, 0, $severity, $file, $line);
}
set_error_handler("exception_error_handler");

自作フレームワークを作ったことがある人なら誰もがとおるアレ

これをエラーと識別するか例外と識別するかは意見が分かれるところ。

できればエラーと呼ばないでほしい

例外

<?php
try {
    $model->save();
} catch(Exception $e) {
    // 握りつぶしてやる!
}

例外はエラーじゃないのよ!   例外とエラーの違いについては色んな記事があるから読んでみるといいかも。

SPL例外みんなもっと使っていきましょ。

Throwable

例外とErrorのインターフェースがThrowableです。
エラーを含んでいますが、エラーじゃないものも含んでいるのです。

思ってたのと違う

trueが返るはずですがfalseが返ってきました。エラーです。

それはエラーじゃない!ただのバグ!

真っ白い画面

何も表示されないんです。エラーです。

たぶんエラーでてると思うけど、レスポンス返してないだけかも。
とりあえずエラーログ出てないか見てみるのと、 display_errorserror_reporting確認してみよ?

Laravel+Codeceptionのメリット

私は普段、Laravel+Codeceptionを利用してテストを書いています。
今回はLaravel+Codeceptionのメリットとちょっとした小技を紹介します。

Codeceptionとは

CodeceptionPHPのテスティングフレームワークです。
単体テスト・機能テスト・受け入れテストを書けます。
多くのフレームワークをサポートしているのが特徴です。

LaravelでCodeceptionを使用するメリット

Laravelにはテスト機能がついています。
特にLaravel5.4からはLaravel Duskがあるので、Codeceptionの機能をすべてカバーしています。
なので、基本的にはわざわざCodeceptionを利用するメリットはありません。
それでもCodeceptionを利用するメリットとしては、以下のようなものがあります。

特定のフレームワークに縛られない

Laravelのテスト機能を使用してテストを書くと、Laravelから他のフレームワークに乗り換えづらくなります。
Codeceptionを利用する場合は、フレームワークを乗り換えてもテストのロジックはそのまま移行できます。

テスティングフレームワークを統一できる

チーム内で使用しているフレームワークが複数ある場合、それぞれのフレームワークのテスト機能を使用してテストを書くと学習コストが高く付きます。
フレームワークのテスト機能を使用せずにCodeceptionで統一すると学習コストを低く抑えられます。

CodeceptionでLaravel5Moduleを使用するメリット

Codeceptionはそれだけで十分にテストを書くことができますが、Laravel5Moduleを使用することでLaravelの機能を利用してテストを書くことができます。

環境ファイルを使用できる

.env.testing という環境ファイルを作成することで、テスト時にデータベースの接続設定などの環境変数を変更することができます。

DB機能を使用できる

テスト実行時に自動でマイグレーションを実行できたり、自動でDatabaseSeederをもとにデータを投入できます。
テスト前にトランザクションを張って、データを変更させないようにもできます。

EloquentModelを使用できる

データベーステストをする際に、 $I->seeRecord(Article::class, ['title' => 'test']) のようにEloquentのモデルを指定することができます。

ルーティングを使用できる

現在のURLをテストする際には

<?php
$I->seeCurrentPageIs("articles/{$article->id}/show")

のように書きますが、代わりに

<?php
$I->seeCurrentRouteIs('articles.show', $article)

のように書けます。 結構シンプルに書けるようになるのでオススメです。

小技:FactoryMuffinを使用しない

Fakerを利用してテストデータを生成したい場合、Codeceptionの公式ドキュメントでは、FactoryMuffinを使用するように書いてあります。
が、以下の理由からあまり使いやすくありません。

  • PHPDocTagの不足でIDEで補完できない
  • FactoryMuffinの定義の中でEloquentを使用できない

LaravelにはすでにFakerを使用してデータを作成する方法が存在するので、FactoryMuffinを使用せずに以下のようなヘルパーを書いて使っています。

<?php
    /**
     * {@inheritdoc}
     *
     * @return \Illuminate\Database\Eloquent\Model
     * @throws \InvalidArgumentException
     */
    public function have($name, $extraAttrs = [])
    {
        return factory($name)->create($extraAttrs);
    }

    /**
     * {@inheritdoc}
     *
     * @return \Illuminate\Support\Collection|\Illuminate\Database\Eloquent\Model[]
     * @throws \InvalidArgumentException
     */
    public function haveMultiple($name, $times, $extraAttrs = [])
    {
        return factory($name, $times)->create($extraAttrs);
    }

よく使うgit操作まとめ

JetBrainsのIDEにはgitとのインテグレーションツールが入っていますが、「gitはコマンドで叩いている」という人が多いようです。 自分の振り返りも兼ねて、よく使うgit操作をまとめてみます。
なお、私は普段はPhpStormを使用しているので、スクリーンショットはPhpStormの画面になります。

コミット

変更がある状態でCommand+Kを押すと、コミット画面になります。 なお、JetBrainsのgitインテグレーションでは、 ステージ の概念はありません。

スクリーンショット 2018-12-01 15.11.21.png (123.1 kB)

  • ① コミットするファイルを選択します。
  • ②コミットメッセージを入力します。右上の時計マークを押すと、過去のコミットメッセージを参照できます。
  • ③変更を確認できます。

    チェックボックスを外すと、その箇所の変更はコミットされません。
    また、ここから直接ファイルを編集することもできます。

  • ④コミット前に静的なチェックを行うことができます。

プッシュ

コミットがある状態でCommand+Shift+Kを押すと、プッシュ画面になります。

スクリーンショット 2018-12-01 21.09.00.png (42.4 kB)

左がプッシュされるコミット、右側がプッシュされるファイルです。

ログ

Command+9を押すと、ログ画面が表示されます。

スクリーンショット 2018-12-01 21.49.46.png (67.0 kB)

左がコミットログ、右が選択したコミットの変更です。
この画面では、コミットを右クリックすることで、色々な操作をできます。

  • コミットハッシュのコピー
  • リセット
  • チェリーピック
  • リビジョンにチェックアウト
  • 新しいブランチの作成
  • 新しいタグの作成
  • リバート
  • インタラクティブリベース(後述)

ブランチ

VCSメニューから Branches を選択すると、ブランチの一覧を表示できます。

スクリーンショット 2018-12-01 22.16.06.png (17.3 kB)

カーソルを合わせることで、色々な操作をできます。

  • カレントブランチの変更
  • ブランチの削除
  • リベース
  • マージ

スタッシュ/アンスタッシュ

VCSメニューから Stash Changes..., UnStash Changes... を選択すると、スタッシュ、アンスタッシュができます。 スタッシュした内容はスタックできます。

インタラクティブリベース

ログ画面からコミットを選び、 Intaractivily Rebase from Here を選択することで、インタラクティブリベースができます。

スクリーンショット 2018-12-01 22.25.12.png (21.5 kB)

内容は見た目がコマンドでリベースするときと一緒です。
右側にある▲▼でコミットの順番を入れ替えられます。

ヒストリー

右クリックで Git -> Show History で、開いているファイルのコミットの履歴を見ることができます。

スクリーンショット 2018-12-01 22.34.27.png (91.9 kB)

また、コードを範囲選択した状態で、右クリックで Git -> Show History from Selection で、選択している部分のみのコミットの履歴を見ることができます。

スクリーンショット 2018-12-01 22.38.02.png (55.7 kB)

おわりに

まとめてみると、結構色々な機能を普段から使ってることに気づかされました。 gitをGUIで使えると、オプションなどをいちいち覚えなくても問題ないのがいいですよね。 また、diffを高機能なdiff画面で見られるのもポイントです。

まだgitの機能を使ったことがない人は、ぜひ使ってみてください!

機能テストはいいぞ!

ふと、機能テストの良さについて語りたくなったので投稿。

単体テストより先に機能テストを書こう!

「テストを書く」というと単体テスト(ユニットテスト)を想像しがちですが、WEBシステムなら機能テスト(HTTPテスト)を先に書くことがおすすめです!

先に用語を整理しておきましょう。
まず、単体テストというのは(基本的に)メソッドや関数が想定通りに動いているかを確認するものです。
次に、機能テストというのは画面に表示されているものが想定通りに表示されているかを確認するものです。1

なぜ機能テストを先に書くといいの?

時間は有限なので、重要なテストから書きたいためです。 もちろん機能テストと単体テストを両方とも書けるといいですが、色々な都合で片方のテストしか書く時間がとれない場合、機能テストを書くことをおすすめします。 なぜなら、機能テストはユーザへの価値提供に直結するテストだからです。

また、機能テストのほうが直感的に書きやすいです。

  1. ストーリーの仕様をそのままテストにすれば良い

    単体テストはメソッド単位の粒度でテストを書くため、先にある程度のクラス設計が必要になります。一方で機能テストはページ単位の粒度でテストを書くため、クラス設計を待たずに書くことができます。

  2. テクニックが必要とされない

    単体テストではテストをしやすくするためのテクニック2が必要になりますが、機能テストではあまり必要になりません。言語の基本構文さえ身に付いていれば書き始めることができます。

機能テストを書くには?

機能テストを書くには、テストライブラリを使用すると良いです。
PHP界隈ではBehatをよく耳にします。が、私は使ったことがありません。
私はよくCodeceptionを使用しています。多くのフレームワークに対応しているのが特徴です。
また、Laravelではテストの機能を内包しており、追加ライブラリなしに機能テストを書くことができます。

機能テストのサンプル

実際にサンプルがないとわかりづらいですよね。
ここからは、LaravelでCodeceptionを利用した場合のサンプルを載せます。

$this->assertEquals(2, 1 + 1); のような何も参考にならない例ではなく、実際のシステムを想定した例を出していきます。

例えば、ブログシステムを考えてみましょう。
このブログシステムのトップページの要件は以下になります。

  • 記事がない場合は「まだ記事がありません」と表示される
  • 新着記事3つが掲載される
  • 非公開フラグの記事は表示されない

ひとつひとつをテストに落とし込んでみましょう。

最初のテスト

まず、トップページが表示されるテストを書きたいと思います。
これは暗黙的な要件ですが、ひとつのテストとして作成しておくと、バグが発生したときに対応しやすくなります。

<?php

public function トップページが表示される(FunctionalTester $I)
{
    $I->amOnPage('/');
    $I->seeResponseCodeIs(HttpCode::OK);
}

これは、「トップページにアクセスするとHTTPステータスコードが200番で返ってくる」というテストです。
ルーティングでミスっていれば404で返り、サーバ内でエラーがあれば500で返るため、バグの切り分けが容易になります。

ちょっとテクニック寄りなテストなのであまり機能テストっぽくありませんね。
次から機能テストらしさを出していきます!

記事がない場合は「まだ記事がありません」と表示される

先にコードを示します。

<?php

public function 記事がない場合は「まだ記事がありません」と表示される(FunctionalTester $I)
{
    $I->amOnPage('/');
    $I->see('まだ記事がありません');
}

どうでしょうか。わかりやすいと思いませんか?
$-> などの記号を抜くと、以下のようになります。

I am on page /  
I see まだ記事がありません

完璧な英語ではないですが、意味が完全にわかる形で読めますね!
これこそが機能テストの真価です!

新着記事3つが掲載される

このケースの場合、記事を事前に用意しておく必要があります。

<?php

public function 新着記事3つが掲載される(FunctionalTester $I)
{
    $newers = $I->haveMultiple(Article::class, 3, ['published_at' => '2001-01-01 00:00:00']);
    $older = $I->have(Article::class, ['published_at' => '2000-01-01 00:00:00']);

    $I->amOnPage('/');

    foreach($newers as $newer)  {
        $I->see($newer->title);
    }
    $I->dontSee($older->title);
}

havehaveMultiple メソッドで記事を生成しています。
また、このテストでは dontSee メソッドを用いて、4つ目の記事が表示されていないことをテストしました。

非公開フラグの記事は表示されない

このテストケースは今までの内容で記述できますね。

<?php

public function 非公開フラグの記事は表示されない(FunctionalTester $I)
{
    $article= $I->have(Article::class, ['publish_status' => PublishStatus::UNPUBLISH]);
    $I->amOnPage('/');
    $I->dontSee($article->title);
}

おわりに

  • 単体テストより先に機能テストを書こう!
  • 機能テストを書くにはテストライブラリを使おう!
  • 自然な表現でテストを書けるぞ!

あまり機能テストをガシガシ書いている人に出会ったことがないので、少しでも広まればいいと思います。


  1. 機能テストではJavaScriptの動作を確認することができません。JavaScriptの動作を確認したい場合は、ブラウザのエミュレータを使用した受入テストを書く必要があります。

  2. DIなど

dockerに入門してみた

docker、名前は聞いたことあるものの、全然ノータッチだったので、勉強してみた。
いつも「dockerとvagrantの違い」あたりを読んで挫折し始めるので、そこらへんはスキップします。
dockerやインフラ初心者にとっては、そこってホントどうでもいいところなんですよね・・・(少なくとも初心者である時点では)
ということで、手を動かすことメインで入門してみました。

参考サイト

www.ogis-ri.co.jp

環境

windows10Pro (10Sからの変更)

dockerインストール

適当にインストーラ落として入れればok

docker ps

さっそくエラーが出る

PS C:\Users\dormi> docker ps
error during connect: Get http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.37/containers/json: open //./pipe/docker_engine: The system cannot find the file specified. In the default daemon configuration on Windows, the docker client must be run elevated to connect. This error may also indicate that the docker daemon is not running.

dockerが立ち上がってない? Win+Qでアプリ検索してdocker立ち上げ。

PS C:\Users\dormi> docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES

無事にでたー!

docker run hello-world

PS C:\Users\dormi> docker run hello-world
Unable to find image 'hello-world:latest' locally
latest: Pulling from library/hello-world
ca4f61b1923c: Pull complete
Digest: sha256:97ce6fa4b6cdc0790cda65fe7290b74cfebd9fa0c9b8c38e979330d547d22ce1
Status: Downloaded newer image for hello-world:latest

Hello from Docker!
This message shows that your installation appears to be working correctly.

To generate this message, Docker took the following steps:
 1. The Docker client contacted the Docker daemon.
 2. The Docker daemon pulled the "hello-world" image from the Docker Hub.
    (amd64)
 3. The Docker daemon created a new container from that image which runs the
    executable that produces the output you are currently reading.
 4. The Docker daemon streamed that output to the Docker client, which sent it
    to your terminal.

To try something more ambitious, you can run an Ubuntu container with:
 $ docker run -it ubuntu bash

Share images, automate workflows, and more with a free Docker ID:
 https://cloud.docker.com/

For more examples and ideas, visit:
  https://docs.docker.com/engine/userguide/

まずlocalのイメージを探して、なければダウンロードするんですね。 というか、番号付きリストのところに何が起こったか書いてありますね。やさしい。

docker pull

PS C:\Users\dormi> docker pull alpine
Using default tag: latest
latest: Pulling from library/alpine
ff3a5c916c92: Pull complete
Digest: sha256:7b848083f93822dd21b0a2f14a110bd99f6efb4b838d499df6d04a49d0debf8b
Status: Downloaded newer image for alpine:latest

run はダウンロードして実行するけど、 pull は落とすだけって感じ?

docker images

PS C:\Users\dormi> docker images
REPOSITORY          TAG                 IMAGE ID            CREATED             SIZE
alpine              latest              3fd9065eaf02        2 months ago        4.15MB
hello-world         latest              f2a91732366c        3 months ago        1.85kB

ダウンロードしたイメージが見れるっぽい。

docker run

PS C:\Users\dormi> docker run alpine echo "hello from alpine"
hello from alpine

コマンドやってることは分かったけど、補完依存症の僕にとっては、イメージ名は補完してほしいな感。
fishとか使ったらいい感じに補完してくれたりしないのかな。

docker run -it

PS C:\Users\dormi> docker run -it alpine bin/sh
/ # ls
bin    dev    etc    home   lib    media  mnt    proc   root   run    sbin   srv    sys    tmp    usr    var

なるほど。 -it でイメージに入れるんですね。 -i-t が何を指してるかは、これからわかるのかな?
(なんとなく想像はつくけど。)

docker ps(2回目)

PS C:\Users\dormi> docker ps
CONTAINER ID        IMAGE               COMMAND             CREATED             STATUS              PORTS               NAMES
209aff4c011b        alpine              "bin/sh"            5 minutes ago       Up 5 minutes                            agitated_sinoussi

起動中のイメージが表示されるのね

docker stop

PS C:\Users\dormi> docker stop 209aff4c011b
209aff4c011b

イメージを停止させるのかな?
ちょっと時間かかる

docker start

PS C:\Users\dormi> docker start 209aff4c011b
209aff4c011b

なるほど。 stop したイメージを再開させるのか。

docker attach

PS C:\Users\dormi> docker attach 209aff4c011b
/ #

これで stop したイメージにまた入れるのね。

docker rm

PS C:\Users\dormi> docker rm 209aff4c011b
Error response from daemon: You cannot remove a running container 209aff4c011b8f7ba6f469ef17540c599125ccad6aff6cfb1a9b2d907792ed7a. Stop the container before attempting removal or force remove

stop しないまま実行しようとすると怒られる

PS C:\Users\dormi> docker stop 209aff4c011b
209aff4c011b
PS C:\Users\dormi> docker rm 209aff4c011b
209aff4c011b

いけた。

PS C:\Users\dormi> docker start 209aff4c011b
Error response from daemon: No such container: 209aff4c011b
Error: failed to start containers: 209aff4c011b

消すともう再開できないらしい。

サーバーを動かす

PS C:\Users\dormi> docker run --name static-site -e AUTHOR="Docker" -d -p 80:80 seqvence/static-site
Unable to find image 'seqvence/static-site:latest' locally
latest: Pulling from seqvence/static-site
fdd5d7827f33: Pull complete
a3ed95caeb02: Pull complete
716f7a5f3082: Pull complete
7b10f03a0309: Pull complete
aff3ab7e9c39: Pull complete
Digest: sha256:41b286105f913fb7a5fbdce28d48bc80f1c77e3c4ce1b8280f28129ae0e94e9e
Status: Downloaded newer image for seqvence/static-site:latest
ff91ee73c66b3b175f148a7d0e2c0bda8f24f9c9a26467df1d2a6c6d4d9034ec

引数多くて「ウっ」てなる。
--name は別にいらんのかなーとも思う。

サイトを表示するためにはデフォルトでバインドされるIPアドレスを調べないといけないらしいが・・・

PS C:\Users\dormi> docker-machine ip default
Docker machine "default" does not exist. Use "docker-machine ls" to list machines. Use "docker-machine create" to add a new one.

なんかエラー出る・・・ とりあえず http://localhost でつながったけど・・・・微妙ですね。
ここの設定をどうやって出せるかは知りたいところ。

ネット徘徊してたらこれでIPアドレスわかるらしいが・・・

PS C:\Users\dormi> docker inspect -f "{{ .NetworkSettings.IPAddress }}" ff91ee73c66b
172.17.0.2

なんか違いそう。 これ内部のIPアドレスよね。

色々調べた結果、Dockerへの接続を知りたいだけなので、

PS C:\Users\dormi> ipconfig

で良かったのだった・・・

PHP-BLT #8 でPHPDocについて発表しました。

PHP-BLTにいってきました

8/8にあったPHP-BLT #8 で「PHPDocのおさらい」というタイトルで発表しました。

内容としては「PHPDocで関数とかの仕様書けるよ!」という当たり前すぎる話を5分に引き伸ばした感じです。 ただこの話は、自分の中で悶々と溜めてたことなので、話せてよかったなーと思います。

今回は初めてのLTということで、事前にスライド見ながら練習とかしてみたんですけど、やっぱり5分は短いですね。
色々話したかったけど時間で話せないこともあったので、ここで発散しておきます。

PHPDocをなぜ書くのか

今回の発表では、「コードレベルの仕様を書きたいから」というのを理由にしてますし、私自身もそれが一番の理由だと思っています。

ですが、「静的解析したいから」という理由の人が一定数います。

もちろん私もPhpStorm大好き人間なので静的解析には大変にお世話になっていますし、静的解析のためのPHPDocも書くことはあります。( @var とか)
ですが、それはあくまで副次的な利点であり、本質は違うんじゃないかなと思います。

やはりPHPDocの源流はJavaDocだと思いっています。 Javaは型ガッチリな言語なので、JavaDocの発展に静的解析はあまり関係していないと思うんですよね。(総称型とかは置いといて)
それと同じように、PHPDocの本来の目的も、静的解析ではなくもっと別のところにあるのかなと思います。

今回はそれを「仕様を書きたいから」というところに着地させています。

仕様は命名からわかるべき

「仕様を書かなくてもわかるようなクラス・メソッド名や関数名にするべき」という意見をよくききます。
リーダブルコードにも書いてある内容ですよね。

もちろんそれはそうなのですが、理想と現実は違うのかなと感じています。
というのも、

  • そんなに英語できる人が少ない(語彙や前置詞の使い方など)
  • 2,3ワードでほんとに仕様を説明できるの?

みたいな問題があるのかなと。

あとは、フレームワークの制約上難しい場合もありますよね。
例えばYii1系だと、 beforeAction というメソッドがあり、名前のとおり各アクションを実行する前に実行されます。
このメソッドを利用する時に、このメソッドの仕様を命名で解決できるかというと、無理ですよね。(どのタイミングで実行されるかは明白だけど、何をするのかはわからない)

もちろん命名が不要なわけではないです。
概要を命名で説明して、細かい仕様をPHPDocで説明するくらいの温度感がいいのかなと思います。

PHP-BLT楽しかった!

ちなみに私以外で発表していた人は「よく勉強会やカンファレンスで喋ってるあの人」みたいな人ばっかりでした。
そして内容も難しい話ばっかりでした。みんな凄いなー。
でも知らないことを知ったり、自分と違う環境の人と話すことはとても楽しいですよね。
また次も参加したいなー。

今回は体調不良だったので、次回は体調良好な状態でいきたい!

laravel5.3 CSVダウンロードを実装する

先日、CSVダウンロードで盛大にコケまくったので、メモ。

わりとコードが長くなってしまったので、fopenが失敗した場合や文字コードの変換については書いていません。

経過なんてどうでもいい!結果だけくれ!という方は下から読むといいと思います。

CSVダウンロードの簡単な実装

頑張って手作りするとこんな感じ。

<?php
    public function downloadCSV()
    {
        $csv = [
            ['id', 'name', 'age'],
            ['1', 'tanaka', '20'],
        ];
        
        $csvContent = '';
        foreach ($csv as $line) {
            $csvContent .= '"' . implode(',', $line) . '"';
            $csvContent .= PHP_EOL;
        }
        
        return response(
            $csvContent,
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

初めて作ってみるとこんな感じになりますよね。
ちなみにこの実装は非常によろしくないです。
CSVのエスケープ処理が充分に考えられていません。(ダブルクォーテーションが値に入ってたら崩れます)

fputcsvを使う

自分でCSVのエスケープを書くのは面倒なので、 fputcsv 関数を使用するように変更します。
fputcsvはfopenの返り値に対して使用できるので、いちど適当にファイルに吐いてから取得するようにします。

<?php
    public function downloadCSV()
    {
        $csv = [
            ['id', 'name', 'age'],
            ['1', 'tanaka', '20'],
        ];
        
        $stream = fopen('/temp/users.csv', 'w');
        foreach ($csv as $line) {
            fputcsv($stream, $line);
        }
        fclose($stream);

        rewind($stream);
        $csvContent = stream_get_contents($stream);

        return response(
            $csvContent,
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

これで正確なCSVを出力できるようになりました。
ですが、データが多いとファイルI/Oが頻繁に発生しそうです。

php://output を使う

いちどファイルに入れずに、直接出力してしまえば、ファイルI/Oは気にしなくて済みます。
ファイルのパスを php://output に変更しましょう。

<?php
    public function downloadCSV()
    {
        $csv = [
            ['id', 'name', 'age'],
            ['1', 'tanaka', '20'],
        ];
        
        $stream = fopen('php://output', 'w');
        foreach ($csv as $line) {
            fputcsv($stream, $line);
        }
        fclose($stream);

        return response(
            '',
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

これでファイルI/Oを気にしなくてよくなりました!
ですが、データが多いとヘッダーが無視され、ブラウザ上に出力されてしまいます!
理由はヘッダーを送る前にデータを送っているからのようです。
おそらく、ブラウザかサーバー(apacheとか)で「こんだけ待ってもヘッダー来ないから、もうデフォルトのヘッダーだと思って出力しちゃえ!」ってなってるんだと思います。

StreamedResponseを使う

先にヘッダーを送ってしまえばこの問題は解決しそうです。
response 関数は第一引数に出力内容を文字列で渡しますが、 StreamedResponse クラスは出力内容をコールバックで渡すことができます。

<?php
    use Symfony\Component\HttpFoundation\StreamedResponse;

    public function downloadCSV()
    {
        return  new StreamedResponse(
            function () {
                $csv = [
                    ['id', 'name', 'age'],
                    ['1', 'tanaka', '20'],
                ];

                $stream = fopen('php://output', 'w');
                foreach ($csv as $line) {
                    fputcsv($stream, $line);
                }
                fclose($stream);
            },
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

データベースの内容を出力する

だいたいのケースにおいて、CSV出力したいものはあらかじめ定義した配列ではなく、データベースの中のデータではないでしょうか。

<?php
    use Symfony\Component\HttpFoundation\StreamedResponse;
    use App\User;

    public function downloadCSV()
    {
        return  new StreamedResponse(
            function () {
                $csv = User::all(['id', 'name', 'age'])->toArray();

                $stream = fopen('php://output', 'w');
                foreach ($csv as $line) {
                    fputcsv($stream, $line);
                }
                fclose($stream);
            },
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

Eloquentは結果を Collection クラスで返すので、忘れずに配列に変換しましょう。
ただ、データが多いとメモリが枯渇してしまいます。

chunkを使う

一度にすべてのデータを取得するのではなく、データを分割して取得し、その都度、出力すればよさそうです。

<?php
    use Symfony\Component\HttpFoundation\StreamedResponse;
    use App\User;

    public function downloadCSV()
    {
        return  new StreamedResponse(
            function () {
                $stream = fopen('php://output', 'w');
                User::chunk(100, function ($users) use ($stream) {
                    foreach ($users as $user) {
                        fputcsv($stream, [$user->id, $user->name, $user->age]);
                    }
                });
                fclose($stream);
            },
            200,
            [
                'Content-Type' => 'text/csv',
                'Content-Disposition' => 'attachment; filename="users.csv"',
            ]
        );
    }

これで問題なさそうです。
CSVダウンロードは考えることが多くて大変ですね。