返回

搞定 Laravel Excel 动态列: MySQL 导出用户分组矩阵

php

用 Laravel 和 Maatwebsite/Excel 从 MySQL 数据生成 Excel 矩阵

碰到的问题挺常见的:我们想从数据库里拉取数据,然后用 maatwebsite/excel 这个包在 Laravel 9 里生成一个 Excel 文件。目标格式长这样:

用户姓 用户名 分组 A 分组 B 分组 X
Doe John X X
Doe Jane X

你有三张表:users (用户表)、groups (分组表),还有一张 group_user (用户分组关联表),用来连接 user_idgroup_id,因为一个用户可以属于多个分组。

你已经成功搞定了动态表头,查询了分组列表,并在前面加上了“用户姓”和“用户名”。

// App\Exports\MyUsersExport.php

use App\Models\Group;
use Maatwebsite\Excel\Concerns\WithHeadings;

class MyUsersExport implements WithHeadings
{
    public function headings(): array
    {
        $header = [];
        // 假设你的 Group 模型是 App\Models\Group
        $groupList = Group::orderBy('name')->get(); // 获取所有分组,建议排序保证列顺序稳定

        foreach($groupList as $group)  {
            $header[] = $group->name; // 直接用分组名
        }

        // 把固定的列名插到最前面
        array_unshift($header,"用户姓", "用户名");

        return $header;
    }

    // ... 其他方法
}

(注意:这里的代码和你原来的有点不一样,直接获取所有 Group 模型实例,并且假设 Group 模型里有个 name 字段。建议加上 orderBy 来保证每次导出的列顺序一致。)

接着,你也查询出了用户数据,包含了他们的姓名和所属分组:

// App\Exports\MyUsersExport.php

use App\Models\MyUser; // 假设你的用户模型是这个
use Maatwebsite\Excel\Concerns\FromArray; // 或者 FromCollection

class MyUsersExport implements FromArray // 或者 FromCollection, WithHeadings
{
    // ... headings() 方法 ...

    public function array(): array // 如果用 FromArray
    {
        $datas = [];
        $myUsers = MyUser::with(['groupMatching.group'])->get(); // 预加载关联关系和关联的分组信息

        foreach ($myUsers as $myUser) {
            $userGroups = [];
            foreach ($myUser->groupMatching as $groupMatch) {
                // 确保 groupMatching 关联到了 group 模型,并且 group 模型有 name 属性
                if ($groupMatch->group) {
                     $userGroups[] = $groupMatch->group->name;
                }
            }

            // 按照你之前的结构构建,但这里只收集信息,后面处理
            $datas[$myUser->id] = [
                "lastname" => $myUser->lastname, // 注意这里用 lastname
                "firstname" => $myUser->firstname, // 这里用 firstname
                "groups" => $userGroups // 存储用户拥有的分组名数组
            ];
        }
        // $datas 结构现在类似:
        // [
        //     4 => ["lastname" => "Doe", "firstname" => "John", "groups" => ["Group A", "Group X"]],
        //     5 => ["lastname" => "Doe", "firstname" => "Jane", "groups" => ["Group X"]]
        // ]

        // 问题来了:怎么把这个 $datas 转换成 Excel 需要的二维数组,
        // 即 ['Doe', 'John', 'X', '', 'X'] 这样的行?
        // 这就是接下来要解决的。

        // 直接返回这个 $datas 是不行的,格式不对。
        // return $datas; // 不能直接这样返回

        // 需要进一步转换... (见下面的解决方案)
        return $this->transformData($datas); // 调用一个转换方法
    }

    // 或者用 FromCollection
    public function collection() // 如果用 FromCollection
    {
         $myUsers = MyUser::with(['groupMatching.group'])->get();
         // 同样需要转换,但可以直接在 Collection 上操作 (见解决方案一)
         // return $myUsers; // 不能直接返回这个

         // 需要进一步转换... (见下面的解决方案)
         return $this->mapUsersToRows($myUsers); // 调用一个转换方法
    }


    // 辅助转换方法(具体实现在下面)
    protected function transformData(array $rawData): array
    {
        // ... 实现数据转换逻辑 ...
        return [];
    }

    protected function mapUsersToRows($users)
    {
        // ... 实现数据转换逻辑 ...
        return collect([]); // 返回一个 Collection
    }
}

你拿到的 $datas 数组大概是这样:

array:2 [▼
  4 => array:3 [▼
    "lastname" => "Doe"
    "firstname" => "John"
    "groups" => array:2 [▼ // 注意这里和原问题略有不同,假设只有两个组了
      0 => "Group A"
      1 => "Group X"
    ]
  ]
  5 => array:3 [▼
    "lastname" => "Doe"
    "firstname" => "Jane"
    "groups" => array:1 [▼
      0 => "Group X"
    ]
  ]
]

下一步该咋办呢?怎么才能在匹配的列名下显示一个 'X',得到目标效果?

你试着找 map 函数和例子,但没找到方向。我们来看看怎么搞定它。

为啥直接导出有点难?

关键在于,maatwebsite/excelFromArrayFromCollection 接口期望你提供一个最终形态 的二维数组或集合。每一项(内层数组或对象/数组)代表 Excel 中的一行 ,并且顺序结构 需要和你 headings() 方法定义的表头完全对应。

你现在拿到的 $datas$myUsers 集合,虽然包含了所有信息,但格式不对。用户分组信息是嵌套在里面的 groups 数组,而 Excel 需要的是扁平化的行数据,每个分组对应一个独立的列,值是 'X' 或者空字符串。

所以,我们需要加一步数据转换:遍历每个用户,根据所有可能的分组 (由 headings() 定义),构建出他们对应的那一行 Excel 数据。

搞定它!解决方案来了

核心思路就是在导出类 (MyUsersExport) 中准备好最终格式的数据。你有两种主要方式可以实现这个转换,使用 FromCollection 通常更符合 Laravel 的风格,但用 FromArray 也可以做到。

核心思路:准备好每一行的数据

不管用哪种方法,核心步骤都差不多:

  1. 获取所有分组名称 :跟 headings() 方法里一样,先拿到一个包含所有可能出现的分组名称的列表。这个列表决定了 Excel 的动态列。最好排序一下,保证每次导出列的顺序不变。
  2. 获取用户信息及其所属分组 :查询用户数据,并预加载 (eager load) 他们关联的分组信息,提高效率。
  3. 遍历用户 :对每个用户进行处理。
  4. 构建单行数据
    • 创建一个数组,开头是固定的用户信息(比如姓、名)。
    • 然后,遍历所有分组名称列表
    • 对于列表中的每个分组名称,检查当前用户 是否属于这个分组。
    • 如果属于,就在行数组里对应位置添加 'X'。
    • 如果不属于,就添加空字符串 ''。
    • 最终得到一个完整代表该用户在 Excel 中一行的数组。
  5. 收集所有行 :把每个用户构建好的行数组,添加到一个总的数组或集合里。
  6. 返回结果 :把这个包含所有行数据的二维数组(如果用 FromArray)或集合(如果用 FromCollection)返回给 maatwebsite/excel

下面我们看具体的代码实现。

方法一:使用 FromCollectionmap() 方法转换集合 (推荐)

这种方法更“Laravel 化”,利用集合 (Collection) 提供的 map() 方法来转换数据。

<?php

namespace App\Exports;

use App\Models\Group;
use App\Models\MyUser;
use Maatwebsite\Excel\Concerns\FromCollection;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Illuminate\Support\Collection;

class MyUsersExport implements FromCollection, WithHeadings
{
    protected $allGroupNames; // 存储所有分组名称,避免重复查询

    public function __construct()
    {
        // 在构造函数里就把所有分组名查好并存起来
        // 保证 headings() 和 collection() 使用的是同一套分组列表和顺序
        $this->allGroupNames = Group::orderBy('name')->pluck('name')->toArray();
    }

    /**
     * 定义 Excel 表头
     * @return array
     */
    public function headings(): array
    {
        $header = $this->allGroupNames; // 直接用存好的分组名
        array_unshift($header, "用户姓", "用户名"); // 添加固定列头
        return $header;
    }

    /**
     * 准备要导出的数据集合
     * @return Collection
     */
    public function collection(): Collection
    {
        // 查询用户,预加载分组信息以提高效率
        // 注意:需要确保 MyUser 模型中定义了 groupMatching 关联,
        // 并且 groupMatching 模型(假设是 GroupUser)定义了到 Group 模型的关联(比如叫 group)
        $users = MyUser::with(['groupMatching.group'])->get();

        // 使用集合的 map 方法来转换每个用户对象为一行数据数组
        return $users->map(function ($user) {
            // 先准备好当前用户拥有的分组名称列表,方便快速查找
            $userGroupNames = [];
            if ($user->groupMatching) { // 检查关联是否存在且不为空
                foreach ($user->groupMatching as $match) {
                    // 确保关联的 group 对象存在且有 name 属性
                    if ($match->group && $match->group->name) {
                        $userGroupNames[] = $match->group->name;
                    }
                }
                // 如果需要快速查找,可以转换成键值对,值为 true
                $userGroupNames = array_flip($userGroupNames); // 例如: ['Group A' => 0, 'Group X' => 1] 变成 ['Group A' => true, 'Group X' => true] 查询更快
            }


            // 开始构建这一行的数据
            $rowData = [
                $user->lastname,  // 第一列:用户姓
                $user->firstname, // 第二列:用户名
            ];

            // 遍历所有可能的分组名称(按表头顺序)
            foreach ($this->allGroupNames as $groupName) {
                // 检查当前用户是否拥有这个分组
                // 使用 isset() 配合 array_flip() 后的数组查找效率高
                if (isset($userGroupNames[$groupName])) {
                    $rowData[] = 'X'; // 属于该分组,填 'X'
                } else {
                    $rowData[] = '';  // 不属于,填空字符串
                }
            }

            // 返回构建好的单行数据数组
            return $rowData;
        });
    }

    /**
     * (可选) 进阶技巧:处理大量数据
     * 如果用户数据非常多,直接 get() 可能导致内存溢出。
     * 可以改用 FromQuery 配合 chunk() 来分块处理。
     * 这需要对导出类结构做些调整,实现 `Maatwebsite\Excel\Concerns\FromQuery` 接口
     * 并可能需要 `Maatwebsite\Excel\Concerns\WithMapping` 来处理每一行映射。
     * 例如,使用 WithMapping 接口:
     *
     * public function query() { return MyUser::query(); } // 不在这里 get()
     * public function map($user): array { // $user 是单个 MyUser 模型实例
     *     // ... 在这里执行上面 map 闭包里的逻辑 ...
     *     // 需要注意的是,每次 map 调用时都需要获取该用户的分组信息
     *     // 这可能会导致 N+1 查询问题,除非你在 query() 里做了 with() 预加载
     *     // 或者在这里单独为每个 $user 查询分组 $user->load('groupMatching.group')
     *     $user->loadMissing('groupMatching.group'); // 只在需要时加载
     *     $userGroupNames = $user->groupMatching->pluck('group.name')->filter()->flip();
     *     $rowData = [$user->lastname, $user->firstname];
     *     foreach ($this->allGroupNames as $groupName) {
     *          $rowData[] = isset($userGroupNames[$groupName]) ? 'X' : '';
     *     }
     *     return $rowData;
     * }
     *
     * 结合 FromQuery 和 WithMapping 可以有效处理大数据量导出。
     */
}

原理说明:

  • 我们实现了 FromCollection 接口,所以需要提供一个 collection() 方法返回最终的 Illuminate\Support\Collection 实例。
  • 在构造函数 __construct() 中预先查好所有分组名称 allGroupNames 并存储起来,这样 headings()collection() 可以共用,保证顺序一致性,也避免了重复查询。
  • collection() 方法首先获取所有用户及其关联的分组信息 (with(['groupMatching.group']) 是关键,避免 N+1 查询问题)。
  • 接着,使用 Laravel Collection 的 map() 方法,它会遍历 $users 集合中的每一个 $user 对象。
  • map() 的闭包函数接收每个 $user 对象,目标是返回一个代表该用户 Excel 行的数组 rowData
  • 在闭包内部:
    • 先整理出当前用户实际拥有的分组名称列表 $userGroupNames。这里做了优化,用 array_flip() 将其转换为键值对(['组名' => true]),这样后面用 isset() 检查会非常快。
    • 初始化 $rowData 数组,放入固定的姓和名。
    • 然后,迭代 所有可能的分组名称列表 ($this->allGroupNames)。
    • 对每个全局分组名 $groupName,使用 isset($userGroupNames[$groupName]) 检查当前用户是否属于该分组。
    • 根据检查结果,向 $rowData 追加 'X' 或空字符串。
    • 最后返回 $rowData
  • map() 方法最终会返回一个新的集合,其中每个元素都是处理好的 $rowData 数组。这个集合正是 FromCollection 所需的格式。

进阶使用技巧:

  • 大数据量处理 :如果用户和分组数量巨大,一次性 get() 所有用户并处理可能耗尽内存。这时可以考虑改用 FromQuery 接口,结合 WithMapping 接口。FromQuery 允许你返回一个查询构建器 (Query Builder),maatwebsite/excel 会自动进行分块查询。WithMapping 则提供一个 map($row) 方法,让你定义如何将数据库中的每一行(或模型实例)映射成 Excel 的一行数据。如上面代码注释中的示例所示,这需要将行构建逻辑移到 map() 方法里。
  • 性能优化 :确保数据库查询已优化,特别是关联查询。在 groupMatchinggroups 表上建立合适的索引 (user_id, group_id) 非常重要。with() 预加载是避免 N+1 查询的关键。array_flip() 技巧可以加速分组检查。

方法二:使用 FromArray 手动构建二维数组 (备选)

如果你更习惯使用 FromArray 接口,或者不想依赖 Laravel Collection 的 map 方法,也可以手动构建最终的二维数组。

<?php

namespace App\Exports;

use App\Models\Group;
use App\Models\MyUser;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;

class MyUsersExport implements FromArray, WithHeadings
{
    protected $allGroupNames;

    public function __construct()
    {
        $this->allGroupNames = Group::orderBy('name')->pluck('name')->toArray();
    }

    public function headings(): array
    {
        $header = $this->allGroupNames;
        array_unshift($header,"用户姓", "用户名");
        return $header;
    }

    /**
     * 准备要导出的数据数组
     * @return array
     */
    public function array(): array
    {
        // 获取所有用户及分组信息
        $users = MyUser::with(['groupMatching.group'])->get();

        $exportData = []; // 初始化最终的二维数组

        foreach ($users as $user) {
            // 获取当前用户拥有的分组名列表
             $userGroupNames = [];
            if ($user->groupMatching) {
                foreach ($user->groupMatching as $match) {
                   if ($match->group && $match->group->name) {
                       $userGroupNames[] = $match->group->name;
                   }
                }
                 // 同样建议转换成键值对加速查找
                 $userGroupNames = array_flip($userGroupNames);
             }

            // 构建单行数据
            $rowData = [
                $user->lastname,
                $user->firstname,
            ];

            // 遍历所有分组名称
            foreach ($this->allGroupNames as $groupName) {
                // 检查用户是否有此分组
                 if (isset($userGroupNames[$groupName])) {
                    $rowData[] = 'X';
                } else {
                    $rowData[] = '';
                }
            }

            // 将构建好的行添加到结果数组中
            $exportData[] = $rowData;
        }

        // 返回包含所有行数据的二维数组
        return $exportData;
    }
}

原理说明:

  • 此方法实现 FromArray 接口,需要提供一个 array() 方法返回一个二维数组。
  • 与方法一类似,先获取所有用户数据和所有分组名称。
  • 初始化一个空数组 $exportData 用于存放最终的表格数据。
  • 使用 foreach 循环遍历每个 $user 对象。
  • 在循环内部,逻辑和方法一的 map 闭包里基本一致:获取用户的分组,构建 $rowData 数组,填充固定的姓、名,然后遍历所有分组名称,根据用户是否属于该分组来追加 'X' 或空字符串。
  • 最后,将完整的 $rowData 数组添加到 $exportData 中。
  • 循环结束后,返回 $exportData 这个二维数组。

对比: 两种方法都能达到目的。方法一(FromCollection + map)通常被认为更符合 Laravel 的集合操作风格,代码可能稍微简洁一些。方法二(FromArray)则更直观地体现了构建二维数组的过程。对于大数据量场景,都需要考虑使用 FromQueryWithMapping 进行优化。

别忘了控制器里的调用

最后,在你的控制器 (Controller) 方法里,像平常一样调用导出类就行了:

<?php

namespace App\Http\Controllers;

use App\Exports\MyUsersExport;
use Maatwebsite\Excel\Facades\Excel;

class ExportController extends Controller
{
    public function exportUsersWithGroups()
    {
        // 定义导出的文件名
        $fileName = 'users_groups_matrix_' . date('YmdHis') . '.xlsx';

        // 直接下载文件
        return Excel::download(new MyUsersExport(), $fileName);

        // 或者,如果你想先存储文件再提供下载链接
        // Excel::store(new MyUsersExport(), 'exports/' . $fileName, 'public');
        // return response()->json(['message' => '文件已生成', 'url' => Storage::disk('public')->url('exports/' . $fileName)]);
    }
}

然后设置一个路由指向这个控制器方法,比如:

// routes/web.php
use App\Http\Controllers\ExportController;

Route::get('/export/users-groups', [ExportController::class, 'exportUsersWithGroups'])->name('export.users.groups');

访问 /export/users-groups 这个 URL,就应该能下载到你想要的 Excel 文件了。

这样,通过在导出类中对原始数据进行转换和映射,你就成功地根据动态的分组列生成了用户-分组矩阵的 Excel 文件。