File Uploads

There are many file upload plugins for CakePHP (See https://github.com/friendsofcake/awesome-cakephp#file-manipulation ) and they have lots of features. But all I want to do is to simply attach documents to my model and ensure they don't clobber one another so I created my own basic functionality that meets my needs without any added overhead.

By default, files are treated in CakePHP as Laminas\Diactoros\UploadedFile objects, so if you'd prefer to work with them as standard PHP files you can add the following to your app_local.php file:

'App' => [
  ...
  'uploadedFilesAsObjects' => false,
],

However, as you'll see below, working with the uploaded file as an object isn't that complicated, once you know what you are doing.

Create The Documents Table

My documents in this instance are attached to Users, but you can associate them with whatever Models you wish. Create a simple table identifying the filename, user, document name, and description.

CREATE TABLE `documents` (
  `id` int UNSIGNED NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `name` varchar(100) NOT NULL,
  `filename` varchar(125) NOT NULL,
  `description` varchar(255) DEFAULT NULL,
  `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `modified` timestamp NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`)
  UNIQUE KEY `filename` (`filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

Or create a migration:

$ bin/cake bake migration CreateDocuments

Then update the migration at /config/Migrations/##############_CreateDocuments.php

  public function change(): void
  {
    $table = $this->table('documents');
    $table->addColumn('user_id', 'integer', ['null' => false])
      ->addColumn('name', 'string', ['limit' => 100, 'null' => false])
      ->addColumn('filename', 'string', ['limit' => 125, 'null' => false])
      ->addColumn('description', 'string')
      ->addTimestamps()
      ->addIndex('filename', ['name' => 'filename', 'unique' => true]);
    $table->create();
  }

Then run your migration:

$ bin/cake migrations migrate

Create The Documents Model

Start with the Entity class /src/Model/Entity/Document.php

<?php
declare(strict_types=1);

namespace App\Model\Entity;

use Cake\ORM\Entity;

class Document extends Entity
{
  protected $_accessible = [
    'user_id' => true,
    'name' => true,
    'filename' => true,
    'description' => true,
    'modified' => true,
    'created' => true,
  ];
}

Then create the Table class /src/Model/Table/DocumentsTable.php

<?php
declare(strict_types=1);

namespace App\Model\Table;

use Cake\ORM\Query\SelectQuery;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\Validation\Validator;

class DocumentsTable extends Table
{
  public function initialize(array $config): void
  {
    parent::initialize($config);
    $this->table('documents');
    $this->displayField('name');
    $this->primaryKey('id');
    $this->addBehavior('Timestamp');
    // Set the relationship with Users
    $this->belongsTo('Users', [
      'foreignKey' => 'user_id',
      'joinType' => 'INNER',
    ]);
  }
   
  public function validationDefault(Validator $validator): Validator
  {
    $validator
      ->notEmptyString('user_id');

    $validator
      ->scalar('name')
      ->maxLength('name', 100)
      ->requirePresence('name', 'create')
      ->notEmptyString('name');
      
    $validator
      ->scalar('filename')
      ->maxLength('filename', 125)
      ->requirePresence('filename', 'create')
      ->notEmptyString('filename')
      ->add('filename', 'unique', ['rule' => 'validateUnique', 'provider' => 'table']);
      
    return $validator;
  }
  
  public function buildRules(RulesChecker $rules)
  {
    $rules->add($rules->isUnique(['filename']), ['errorField' => 'filename']);
    $rules->add($rules->existsIn('user_id', 'Users'), ['errorField' => 'user_id']);
    return $rules;
  }
}

Edit The Users Model

Since our Documents belong to Users, we need to update its Table file at /src/Model/Table/UsersTable.php and add the following line to the initialize() function:

    $this->hasMany('Documents');

Edit the Users Controller

When loading a User we have to be sure to "contain" the related Documents in order to pull the related data into our query. In the Controller update the view() function.

In /src/Controller/UsersController.php

  public function view($slug = null)
  {
    $query = $this->Users->findBySlug($slug)
      ->contain(['PhoneNumbers', 'Documents']); // Contains the related data
    $user = $query->first();
    ...

Create Display and Links in User View

We'll be adding documents from within the User View, as well as listing the related documents, so edit the view in /templates/Users/view.php and add the following at the end of the file:

  ...
  <div class="related">
    <?php echo $this->Html->link(__('New Document'), ['controller' => 'Documents', 'action' => 'add', $user->id], ['class' => 'button float-right']) ?>
    <h3><?php echo __('Documents') ?></h3>
    <?php if (!empty($user->documents)): ?>
    <div class="table-responsive">
      <table>
        <tr>
          <th><?php echo __('Name') ?>
          <th><?php echo __('Description') ?>
          <th class="actions"><?php echo __('Actions') ?>
        </tr>
        <?php foreach ($user->documents as $document): ?>
        <tr>
          <td><?php echo $this->Html->link($document->name, '/files/' . $document->filename) ?>
          <td><?php echo h($document->description) ?></td>
          <td class="actions">
            <?php echo $this->Html->link(__('View'), '/files/' . $document->filename) ?> |
            <?php echo $this->Form->postLink(__('Delete'), ['controller' => 'Documents', 'action' => 'delete', $document->id], ['confirm' => __('Are you sure you want to delete {0}?', $document->name)]) ?>
          </td>
        </tr>
        <?php endforeach; ?>
      </table>
    </div>
    <?php endif; ?>
   </div>
   ...

Create The Documents Controller

We will be viewing the documents in the User view, so won't need index(), edit(), or view() functions, but you might want to add them for later functionality or help troubleshooting. Create the Controller at /src/Controller/DocumentsController.php

<?php
declare(strict_types=1);

namespace App\Controller;

class DocumentsController extends AppController
{
  public function add($user_id = null) {
    if (is_null($user_id)) { // Documents MUST be attached to a user
      $this->redirect(['controller' => 'Users', 'action' => 'index']);
    }
    $document = $this->Documents->newEmptyEntity();
    if ($this->request->is('post')) {
      $data = $this->request->getData();
      $file = $this->request->getUploadedFile('file');
      if ($file->getError() == 0) {
        $fileExtension = pathinfo($file->getClientFilename(), PATHINFO_EXTENSION);
        // Only allow gif, png, or jpgs
        if (!in_array($fileExtension, ['gif', 'png', 'jpg', 'jpeg', 'jfif'])) {
          $this->Flash->error('Invalid file type. Only upload JPG, PNG, or GIF files.');
          return $this->redirect(['action' => 'add', $user_id]);
        }
        // timestamp files to prevent clobber, replace spaces
        $filename = time() . '-' . str_replace(' ', '_', $file->getClientFilename());
        $destination = WWW_ROOT . 'files' . DS . $filename; 
        $file->moveTo($destination);
        $data['filename'] = $filename;
        $this->request = $this->request->withParsedBody($data);
        $document = $this->Documents->patchEntity($document, $this->request->getData());
        if ($this->Documents->save($document)) {
          $this->Flash->success(__('The document has been saved.'));
          $user = $this->Documents->Users->get($user_id);
          return $this->redirect(['controller' => 'Users', 'action' => 'view', $user->slug]);
        } else {
          $this->Flash->error(__('The document could not be saved. Please, try again.'));
        }
      } else {
        $this->Flash->error('Error uploading file. Please try again.');
      }
    }
    $this->set(compact('document', 'user_id'));
  }

  /**
   * Delete method
   */
  public function delete($id = null)
  {
    $this->request->allowMethod(['post', 'delete']);
    $document = $this->Documents->get($id, [
      'contain' => ['Users'],
    ]);
    // Delete the file
    $filePath = WWW_ROOT . 'files' . DS . $document->filename;
    unlink($filePath);
    if ($this->Documents->delete($document)) {
      $this->Flash->success(__('The document has been deleted.'));
    } else {
      $this->Flash->error(__('The document could not be deleted. Please, try again.'));
    }
    return $this->redirect(['controller' => 'Users', 'action' => 'view', $document->user->slug]);
  }
}

Since we're writing the files to the /webroot/files/ directory of our application, ensure that the directory exists and that the web user (e.g. apache) has write access to that directory. Also we don't want to include uploaded files in our GIT repository (if you have one) so be sure to add the directory to your /.gitignore file.

Create Documents View

The add View should be in /templates/Documents/add.php

<div class="row">
  <div class="column column-100">
    <div class="documents form content">
      <?= $this->Form->create($document, ['type' => 'file']) ?>
      <fieldset>
        <legend><?= __('Add Document') ?></legend>
        <?php
          echo $this->Form->hidden('user_id', ['value' => $user_id]);
          echo $this->Form->control('name');
          echo $this->Form->control('file', ['type' => 'file', 'required' => true]);
          echo $this->Form->control('description');
        ?>
      </fieldset>
      <?= $this->Form->button(__('Submit')) ?>
      <?= $this->Html->link(__('Cancel'), ['controller' => 'Users', 'action' => 'view', $user_id], ['class' => 'button']) ?>
      <?= $this->Form->end() ?>
    </div>
  </div>
</div>