ThinkPHP 8 集成 MinIO 实现文件上传/删除/下载完整教程
一、环境准备:安装依赖
MinIO 兼容 S3 API,可通过 AWS S3 SDK 操作。使用 Composer 安装依赖:
1
| composer require aws/aws-sdk-php "^3.0"
|

图:Composer 安装 aws-sdk-php 依赖成功
二、配置文件:创建 MinIO 连接配置
在 ThinkPHP 8 的 config 目录下创建 minio.php,填写 MinIO 连接参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| <?php
return [ 'endpoint' => env('MINIO_ENDPOINT', 'http://192.168.20.147:9000'), 'use_path_style_endpoint' => true, 'credentials' => [ 'key' => env('MINIO_ACCESS_KEY', '5QEARuEh9VzSusd9qCAO'), 'secret' => env('MINIO_SECRET_KEY', 'TO6TdWpTpkldCyvaov69XBf6EcOvuQGoVyDkXYOW'), ], 'region' => env('MINIO_REGION', 'us-east-1'), 'ssl.certificate_authority' => env('MINIO_SSL', false), 'bucket' => env('MINIO_BUCKET', 'articleinnerimg'), ];
|
⚠️ 注意:
- 存储桶需提前在 MinIO 控制台手动创建
- Access Key/Secret Key 需从 MinIO 控制台获取
- 本地测试时
ssl 建议设为 false,生产环境若配置 HTTPS 需设为 true
三、核心功能:文件上传逻辑
3.1 初始化 MinIO 连接
通过 S3 SDK 初始化连接,参数直接读取配置文件:
1 2 3 4 5 6 7 8 9 10
| $config = config('minio');
$s3Client = new S3Client([ 'endpoint' => $config['endpoint'], 'use_path_style_endpoint' => $config['use_path_style_endpoint'], 'credentials' => $config['credentials'], 'region' => $config['region'], ]);
|
关键参数说明:
| 参数 |
作用 |
注意事项 |
endpoint |
MinIO 服务地址(含端口) |
本地示例:http://192.168.20.147:9000 |
use_path_style_endpoint |
路径风格访问(推荐 true) |
自托管 MinIO 必须开启,虚拟主机风格需配置 DNS |
credentials |
认证信息 |
从 MinIO 控制台「Access Keys」中创建 |
region |
区域标识 |
非必填,可随意填写(如 us-east-1) |
3.2 上传核心:putObject 方法
通过 putObject 上传文件,需指定存储桶、文件索引(Key)、文件内容(Body)和 MIME 类型:
1 2 3 4 5 6 7 8 9 10 11 12 13
| $filename = "DemoFile/20251118/" . uniqid() . "_" . $originalName;
$result = $s3Client->putObject([ 'Bucket' => $config['bucket'], // 存储桶名称 'Key' => $filename, // 文件索引(含目录结构,模拟文件夹) 'Body' => fopen($imgFile->getPathname(), 'r'), // 文件流 'ContentType' => $imgFile->getMime(), // 文件MIME类型 ]);
$fileUrl = $config['endpoint'] . '/' . $config['bucket'] . '/' . $filename;
|
3.3 完整上传控制器代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82
| <?php namespace app\controller;
use think\facade\Request; use think\facade\Config; use Aws\S3\S3Client; use Aws\Exception\AwsException;
class DemoController {
public function upload() { return view("demo/upload"); }
public function img(string $prefix = "DemoFile") { $imgFile = Request::file("image"); if (!$imgFile) { return json(['code' => 1, 'msg' => '请选择文件']); } if (!$imgFile->isValid()) { return json(['code' => 1, 'msg' => '文件无效:' . $imgFile->getError()]); }
try { $config = Config::get('minio');
$s3Client = new S3Client([ 'endpoint' => $config['endpoint'], 'use_path_style_endpoint' => $config['use_path_style_endpoint'], 'credentials' => $config['credentials'], 'region' => $config['region'], ]);
$originalName = basename($imgFile->getOriginalName()); $dateDir = date('Ymd'); $dirPath = "{$prefix}/{$dateDir}/"; $filename = $dirPath . uniqid() . '_' . $originalName;
$s3Client->putObject([ 'Bucket' => $config['bucket'], 'Key' => $filename, 'Body' => fopen($imgFile->getPathname(), 'r'), 'ContentType' => $imgFile->getMime(), ]);
$fileUrl = $config['endpoint'] . '/' . $config['bucket'] . '/' . $filename;
return json([ 'code' => 0, 'msg' => '上传成功', 'data' => [ 'url' => $fileUrl, 'filename' => $filename // 存储文件索引(用于后续删除/下载) ] ]);
} catch (AwsException $e) { return json(['code' => 1, 'msg' => '上传失败:' . $e->getMessage()]); } catch (\Exception $e) { return json(['code' => 1, 'msg' => '系统异常:' . $e->getMessage()]); } } }
|
3.4 前端上传页面(含进度条)
创建 view/demo/upload.html 文件,支持图片选择、上传进度显示、结果反馈:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198
| <!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>MinIO文件上传演示</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } .upload-container { max-width: 600px; margin: 50px auto; padding: 25px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); } h2 { color: #333; margin-bottom: 20px; text-align: center; } .upload-form { display: flex; flex-direction: column; gap: 15px; } .file-input { padding: 10px; border: 2px dashed #ccc; border-radius: 4px; transition: border-color 0.3s; } .file-input:hover { border-color: #4096ff; } .submit-btn { padding: 12px; background-color: #4096ff; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 16px; transition: background-color 0.3s; } .submit-btn:hover { background-color: #1677ff; } .progress-container { height: 8px; background-color: #f0f0f0; border-radius: 4px; overflow: hidden; display: none; } .progress-bar { height: 100%; background-color: #52c41a; width: 0%; transition: width 0.3s; } .message { padding: 10px; border-radius: 4px; margin-top: 10px; display: none; } .success { background-color: #f6ffed; color: #52c41a; border: 1px solid #b7eb8f; } .error { background-color: #fff2f0; color: #f5222d; border: 1px solid #ffccc7; } .file-info { margin-top: 10px; font-size: 14px; color: #666; display: none; } .file-info a { color: #4096ff; text-decoration: none; word-break: break-all; } </style> </head> <body> <div class="upload-container"> <h2>MinIO文件上传演示</h2> <form id="uploadForm" class="upload-form"> <input type="file" name="image" class="file-input" accept="image/*" required> <div class="progress-container"> <div class="progress-bar" id="progressBar"></div> </div> <button type="submit" class="submit-btn">上传文件</button> <div class="message" id="message"></div> <div class="file-info" id="fileInfo"> <p>文件URL: <a id="fileUrl" target="_blank"></a></p> <p>文件索引(Key): <span id="fileName"></span></p> </div> </form> </div>
<script> const form = document.getElementById('uploadForm'); const progressBar = document.getElementById('progressBar'); const progressContainer = progressBar.parentElement; const message = document.getElementById('message'); const fileInfo = document.getElementById('fileInfo'); const fileUrl = document.getElementById('fileUrl'); const fileName = document.getElementById('fileName');
form.addEventListener('submit', async (e) => { e.preventDefault();
const fileInput = form.querySelector('input[type="file"]'); const file = fileInput.files[0];
if (!file) { showMessage('请选择要上传的图片', 'error'); return; } if (!file.type.startsWith('image/')) { showMessage('请上传图片文件(jpg、png、gif等)', 'error'); return; }
const formData = new FormData(); formData.append('image', file);
try { progressContainer.style.display = 'block'; progressBar.style.width = '0%'; hideMessage(); fileInfo.style.display = 'none';
const response = await fetch('/DemoController/img', { method: 'POST', body: formData, onprogress: (e) => { if (e.lengthComputable) { const percent = (e.loaded / e.total) * 100; progressBar.style.width = `${percent}%`; } } });
const result = await response.json();
if (result.code === 0) { showMessage('上传成功', 'success'); fileInfo.style.display = 'block'; fileUrl.href = result.data.url; fileUrl.textContent = result.data.url; fileName.textContent = result.data.filename; } else { showMessage(`上传失败: ${result.msg}`, 'error'); }
} catch (error) { showMessage(`网络错误: ${error.message}`, 'error'); } finally { setTimeout(() => { progressContainer.style.display = 'none'; }, 3000); } });
function showMessage(text, type) { message.textContent = text; message.className = 'message ' + type; message.style.display = 'block'; }
function hideMessage() { message.style.display = 'none'; } </script> </body> </html>
|
3.5 上传测试效果
- 访问
http://你的域名/DemoController/upload 进入上传页面
- 选择图片并点击「上传文件」,可看到进度条动态加载
- 上传成功后显示文件 URL 和索引(Key)

图:MinIO 上传页面演示
上传成功后,MinIO 控制台会显示分级目录结构的文件:

图:分级存储效果(DemoFile/日期/文件名)
✨ 核心优化:通过 prefix/日期/文件名 的结构模拟目录,解决了根目录文件混乱的问题,方便后续管理。MinIO 本身没有真实目录,而是通过 Key 中的 / 实现分级。
四、扩展功能:文件删除
通过文件索引(Key)删除 MinIO 中的文件,需传递完整的 Key(含目录结构):
4.1 删除控制器方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
|
public function deleteImg(string $key) { if (empty($key)) { return json(['code' => 1, 'msg' => '请传入要删除的文件Key']); }
try { $config = Config::get('minio'); $s3Client = new S3Client([ 'endpoint' => $config['endpoint'], 'use_path_style_endpoint' => $config['use_path_style_endpoint'], 'credentials' => $config['credentials'], 'region' => $config['region'], ]);
$result = $s3Client->deleteObject([ 'Bucket' => $config['bucket'], 'Key' => $key // 必须传递完整的文件索引(含目录) ]);
if ($result->get('@metadata')['statusCode'] === 204) { return json(['code' => 0, 'msg' => '图片删除成功']); } else { return json(['code' => 1, 'msg' => '图片删除失败,状态异常']); }
} catch (AwsException $e) { return json(['code' => 1, 'msg' => '删除失败:' . $e->getMessage()]); } }
|
4.2 调用方式
通过 GET/POST 请求传递 key 参数即可:
1
| GET: http://你的域名/DemoController/deleteImg?key=DemoFile/20251118/691c71a48050d_xxx.png
|
删除成功后,MinIO 控制台对应的文件会被移除:

图:文件删除成功反馈
五、扩展功能:文件下载
通过文件索引(Key)下载 MinIO 中的文件,支持自定义下载文件名:
5.1 下载控制器方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
|
public function downloadImg(string $key, ?string $downloadName = null) { if (empty($key)) { return json(['code' => 1, 'msg' => '请传入要下载的文件Key']); }
try { $config = Config::get('minio'); $s3Client = new S3Client([ 'endpoint' => $config['endpoint'], 'use_path_style_endpoint' => $config['use_path_style_endpoint'], 'credentials' => $config['credentials'], 'region' => $config['region'], ]);
$result = $s3Client->getObject([ 'Bucket' => $config['bucket'], 'Key' => $key ]);
if (empty($downloadName)) { $downloadName = basename($key); }
$mimeType = $result->get('ContentType') ?? 'application/octet-stream'; $fileContent = (string)$result->get('Body');
$response = response($fileContent) ->header('Content-Type', $mimeType) ->header('Content-Disposition', 'attachment; filename="' . rawurlencode($downloadName) . '"') ->header('Content-Length', strlen($fileContent));
return $response;
} catch (AwsException $e) { return json(['code' => 1, 'msg' => '下载失败:' . $e->getMessage()]); } }
|
5.2 调用方式
默认文件名下载:
1
| GET: http://你的域名/DemoController/downloadImg?key=DemoFile/20251118/691c71a48050d_xxx.png
|
自定义文件名下载:
1
| GET: http://你的域名/DemoController/downloadImg?key=DemoFile/20251118/691c71a48050d_xxx.png&downloadName=自定义图片.png
|
下载效果:浏览器会自动触发文件下载,文件名符合预期:

图:文件下载成功反馈
六、关键注意事项
- 存储桶权限:确保 MinIO 存储桶已开启「读/写」权限,否则会出现上传/删除失败
- 文件索引(Key):删除/下载时必须传递完整的 Key(含目录结构),否则会提示「文件不存在」
- 异常处理:代码中已包含 AWS SDK 异常和通用异常捕获,便于问题排查
- 性能优化:大文件上传建议使用分片上传(需扩展 SDK 功能),本示例适用于普通图片/小文件
- 安全建议:
- 生产环境建议启用 SSL(HTTPS)
- Access Key/Secret Key 建议通过环境变量配置(
.env 文件),避免硬编码
- 对上传文件进行大小限制(如
$imgFile->getSize() <= 1024*1024*5 限制5MB)
- 对文件类型进行严格校验(避免上传恶意文件)
七、常见问题排查
上传失败:无法连接 MinIO
- 检查 MinIO 服务是否启动
- 检查
endpoint 地址和端口是否正确
- 检查服务器防火墙是否开放 MinIO 端口(默认9000)
上传失败:Access Denied
- 检查 Access Key/Secret Key 是否正确
- 检查存储桶是否存在
- 检查存储桶权限是否为「读/写」
文件无法访问
- 检查 MinIO 存储桶是否开启「公开访问」(或配置访问策略)
- 检查文件 URL 是否正确(格式:
endpoint/bucket/key)
删除/下载失败:文件不存在
- 检查传递的
key 是否完整(含目录结构)
- 检查
key 中的大小写是否与 MinIO 中一致(MinIO Key 区分大小写)