DEV Community

Cover image for How to use Fauna with GraphQL and PHP
Fauna for Fauna, Inc.

Posted on • Originally published at fauna.com

How to use Fauna with GraphQL and PHP

Fauna is a flexible, distributed document-relational database delivered as a secure and scalable cloud API with built-in support for GraphQL. Fauna has a multimodel database paradigm, in that it has the flexibility of a NoSQL database with the relational querying and transactional capabilities of a SQL database. Fauna was built with modern applications in mind, and is a good fit for cloud-native applications that need a reliable, ACID-compliant, cloud-based service to support audiences in multiple regions.

Fauna provides unique features such as:

  • Ability to fetch nested data in a single call
  • Optimized bandwidth usage
  • Minimal administrative overhead
  • Low transactional latency transactional with a fast read/write operationβ€”no matter where your client is running
  • Powerful NoSQL queries, including the ability to make joins and program business logic using a very expressive native query language
  • Advanced indexing
  • Multitenancy,
  • Database implementation abstraction layer via GraphQL

These features make Fauna well suited for handling large amounts of structured and unstructured data.

This guided tutorial shows you how you can use Fauna and PHP to build powerful applications.

You will build a simple project management application that uses Fauna's GraphQL API. In this article, you'll learn more about how Fauna works with GraphQL and PHP. You'll be taken through the process of building a simple project management application that uses GraphQL and Fauna for its CRUD operations. This is what your finished application will look like:

A GIF showing the end result of project to be built

If you'd like to see all the source code at once, it can be found here.

Building with Fauna, GraphQL, and PHP

In this section, you'll learn how a simple project manager called Faproman is built using Fauna, GraphQL, and PHP. Vanilla PHP is used here to keep things simple. The source code for the application can be found here.

Prerequisites

  • A Fauna account, which can be created here.
  • Basic knowledge of GraphQL and object-oriented PHP.
  • PHP and Composer installed. XAMPP is an open source Apache distribution containing PHP. Composer is a dependency manager for PHP.

πŸ’‘Check out the Fauna workshop for GraphQL developers, a self-paced exploration of Fauna’s GraphQL features.

Creating a Fauna database

Fauna offers the ability to import GraphQL schemas. When a schema is imported, Fauna:

  • Creates collections for the data types in the schema, excluding the embedded types.
  • Adds a document id (_id) and timestamp (ts) fields to every document that will exist in the collection. Documents of the same type belong to the same collection.
  • Creates indexes for every named query, with search parameters derived from the field parameters for each relation between types.
  • Creates helper queries and mutations to help with CRUD operations on the created collections.
  • Provides a GraphQL endpoint and playground with Schema and Docs tabs to view the list of possible queries and types available.

You'll begin by creating a Fauna database. Log in to your Fauna Dashboard. The dashboard for a newly registered user looks like this:

View of Fauna's dashboard for newly registered users

Click on Create Database.

On the Create Database form, fill in the Name field with the database name (such as Faproman) and select your region group. The Region Group field lets you decide the geographic region where your data will reside. If you're not sure what region to choose, select Classic (C).

New database form

Leave the Use demo data field unchecked, as no demo data is needed for this project.

Click CREATE, and your new database will be created.

Uploading a GraphQL schema

Before any GraphQL API interaction with our database can occur, Fauna needs a GraphQL schema. First, let's define the structure of the schema that suits the application.

The project management application to be built has a one-to-many relationship, because one user can have many projects. This kind of relationship can be represented in a GraphQL schema by two types: the source (for user) and the target (for projects). The source type will have an array field pointing to the target type, while the target will have a non-array field pointing back to the source.

The source (User) and target (Project) types are given below:

type User {
    username: String! @unique
    password: String!
    project: [Project!] @relation # points to Project
    create_timestamp: Long!
    # _id: Generated by Fauna as each document's unique identifier
    # _ts: Timestamp generated by Fauna upon object updating
}

type Project {
    name: String!
    description: String!
    owner: User! # points to User
    completed: Boolean
    create_timestamp: Long
    # _id: Generated by Fauna as each document's unique identifier
    # _ts: Timestamp generated by Fauna upon object updating
}
Enter fullscreen mode Exit fullscreen mode

The @relation directive on the project field of the User type helps to ensure that Fauna recognizes the field as one that creates a relationship, rather than an array of IDs. The @unique directive on the username field of the User types ensures that no two users can have the same username.

The Query type is defined below:

type Query {
    findUserByUsername(username: String!): User @resolver(name: "findUserByUsername") # find a user by username
    findProjectsByUserId(owner_id: ID!): [Project] @resolver(name: "findProjectsByUserId") #find projects belonging to a user
}
Enter fullscreen mode Exit fullscreen mode

The @resolver directive on the query fields defines the user-defined function to be used to resolve the queries. The name field specifies the name of the function.

The Mutation type is defined below:

type Mutation {
    createNewProject(
        owner_id: ID!
        name: String!
        description: String!
        completed: Boolean,
        create_timestamp: Long
    ): Project @resolver(name: "createNewProject")

    createNewUser(
        username: String!
        password: String!
        create_timestamp: Long
    ): User @resolver(name: "createNewUser")

    deleteSingleProject(id: ID!): Project @resolver(name: "deleteSingleProject")

    updateSingleProject(
        project_id: ID!
        name: String!
        description: String!
        completed: Boolean,
        create_timestamp: Long
    ): Project @resolver(name: "updateSingleProject")

}
Enter fullscreen mode Exit fullscreen mode

The @resolver directive on the mutation fields also defines the user-defined function to be used to resolve the mutations. These mutations will help create, update, and delete new projects and create new users.

Create a file named schema.gql and paste the source, target, query, and mutation types inside it. The schema is now ready to be uploaded.

To upload, click on GraphQL from the sidebar on your Fauna dashboard. You'll see an interface that looks like this:

New user GraphQL section

Click on IMPORT SCHEMA, then select the schema.gql file you created earlier. After the upload completes successfully, you'll see the GraphQL playground, which you can use to interact with the GraphQL API of your Fauna database.

Note that aside from the queries and mutation specified in the schema, Fauna automatically creates basic CRUD queries and mutations for both the source (User) and target (Project) types. These auto generated queries and mutations are automatically resolved by Fauna, meaning you can start using them right off the bat. Custom resolvers have to be created to use the queries and mutations specified in the schema fileβ€”you'll create them later in the tutorial.

Client setup

In this subsection, you'll set up the project management application on your local machine.

Project structure

The file structure of Faproman is given below:

faproman                                             
β”œβ”€ app                                               
β”‚  β”œβ”€ controller                                     
β”‚  β”‚  β”œβ”€ ProjectController.php                       
β”‚  β”‚  └─ UserController.php                          
β”‚  β”œβ”€ helper                                         
β”‚  β”‚  └─ View.php                                    
β”‚  └─ lib                                            
β”‚     └─ Fauna.php                                   
β”œβ”€ public 
β”‚  β”œβ”€ .htaccess
β”‚  β”œβ”€ assets                                         
β”‚  β”‚  └─ style.css                                   
β”‚  └─ index.php                                      
β”œβ”€ temp                                              
β”‚  β”œβ”€ 404.php                                        
β”‚  β”œβ”€ footer.php                                     
β”‚  β”œβ”€ header.php                                     
β”‚  └─ nav.php                                                                     
β”œβ”€ views                                             
β”‚  β”œβ”€ edit.php                                       
β”‚  β”œβ”€ home.php                                       
β”‚  β”œβ”€ login.php                                      
β”‚  └─ register.php                                   
β”œβ”€ composer.json                                     
└─ composer.lock   
Enter fullscreen mode Exit fullscreen mode

Create project directory

Locate the htdocs folder, or the directory that your Apache web server looks for files to serve by default, and create a folder in that directory called faproman. For XAMPP, the directory is found in /xampp/htdocs.

Create a public subfolder inside faproman directory. Your Apache server will be reconfigured later on to serve files from the public folder specifically for Faproman.

Set up a virtual host locally

A virtual host is an Apache server configuration rule that allows for the specification of a site's document root (the directory or folder containing the site's files). A virtual host will help you set up a local domain, like faproman.test, that points to the project's public folder any time the domain is accessed.

To set up a virtual host on your local machine, begin by updating the hosts file of your operating system. The hosts file maps host names to IP addresses.

  • For Windows OS, locate the hosts file at C:/Windows/System32/drivers/etc/hosts
  • For Mac OS, locate the hosts file at /etc/hosts

Open the file in your preferred editor and add the below at the bottom:

127.0.0.1   localhost
127.0.0.1   faproman.test # for Faproman app
Enter fullscreen mode Exit fullscreen mode

Update the virtual hosts file.

On Windows

On Windows OS (xampp), the file is located at c:/xampp/apache/conf/extra/httpd-vhosts.conf.

Open the file in your preferred editor and add the below at the bottom:

<VirtualHost *:80>
    DocumentRoot "C:/xampp/htdocs"
    ServerName localhost
</VirtualHost>

<VirtualHost *:80>
    DocumentRoot "C:/xampp/htdocs/faproman/public" #serve the public folder
    ServerName faproman.test
</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

On Mac

On Mac OS (xampp), the file is located at /Applications/XAMPP/xamppfiles/etc/extra/httpd-vhosts.conf.

Open the file in your preferred editor and add the below at the bottom:

<VirtualHost *:80>
    DocumentRoot "/Applications/XAMPP/xamppfiles/htdocs"
    ServerName localhost
    ServerAlias www.localhost
</VirtualHost>

<VirtualHost *:80>
    DocumentRoot "/Applications/XAMPP/xamppfiles/htdocs/faproman/public"
    ServerName faproman.test
    ServerAlias www.faproman.test
</VirtualHost>
Enter fullscreen mode Exit fullscreen mode

For both operating systems

Next, create an index.php file inside of /htdocs/faproman/public, and add the below code inside it to output the text β€œThis is a test”:

   <?php
   echo "This is a test";
Enter fullscreen mode Exit fullscreen mode

.
Start your Apache server via xampp's control panel.

Navigate to http://faproman.test or http://faproman.test:8080 in your browser. You’ll see the text you just output.

Homepage

Install required dependencies

Create a composer.json file in the root directory of Faproman (/htdocs/faproman) and add the following code:

{
    "autoload": {
        "psr-4": {
            "App\\": "app/"
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures that any namespace starting with App\ is mapped to the app/ folder in the application.

Open your terminal in the faproman directory, and run the command below to install all the required dependencies:

composer require altorouter/altorouter gmostafa/php-graphql-client vlucas/phpdotenv
Enter fullscreen mode Exit fullscreen mode

This installs the following packages:

  • altorouter/altorouter: For managing routes.
  • gmostafa/php-graphql-client: For making requests to a GraphQL endpoint.
  • vlucas/phpdotenv: For loading environment variables from .env files.

Create the required folders and files

Create all the folders and files inside the faproman folder to match the project structure above. Open the faproman folder in your favorite code editor.

Handle routing

The .htaccess config file can be used to make Apache servers redirect requests to all PHP files in the application to a single PHP file.

Open the /public/.htaccess file and paste in the below configuration:

RewriteEngine on
RewriteBase /
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule . index.php [L]
Enter fullscreen mode Exit fullscreen mode

This will redirect requests to the index.php file in the public directory, allowing you to better handle routing.

Open the public/index.php file and paste in the code below to set up routing using the altorouter/altorouter package:

<?php
/**
 * Autoload classes
 */ 
require __DIR__ . '/../vendor/autoload.php';

$router = new AltoRouter();

// routes
$router->addRoutes(array());

/* Match the current request */
$match = $router->match();

// call closure or throw 404 status
if( is_array($match) && is_callable( $match['target'] ) ) {
    // call the action function and pass parameters
    call_user_func_array( $match['target'], $match['params'] ); 
} else {
    // no route was matched
    header("HTTP/1.0 404 Not Found");
    echo "404 | Not Found"
}
Enter fullscreen mode Exit fullscreen mode

Handle views

Open the app/helper/View.php file and enter the following code to create a helper class that will render the view files in the view directory.

<?php

namespace App\Helper;

class View {

    private static $path = __DIR__ . '/../../views/';
    private static $tempPath = __DIR__ . '/../../temp/';

    public static function render(string $view, array $parameters = array(), string $pageTitle = 'Faproman') {
        // make page title available
        $pageTitle = $pageTitle;
        // extract the parameters into variables
        extract($parameters, EXTR_SKIP);
        require_once(self::$tempPath . 'header.php');
        require_once(self::$tempPath . 'nav.php');
        require_once(self::$path . $view);
        require_once(self::$tempPath . 'footer.php');
    }

    public static function render404() {
        $pageTitle = '404 | Not Found - Faproman';
        require_once(self::$tempPath . 'header.php');
        require_once(self::$tempPath . '404.php');
        require_once(self::$tempPath . 'footer.php');
    }

}

Enter fullscreen mode Exit fullscreen mode

Create controller classes

A controller class is used to group request handling logic in methods. There are two controllers in the application.

  • UserController: handles all requests related to users.
  • ProjectController: handles all requests related to the project.

Open the app/controller/UserController.php file and create the UserController class as shown below:

<?php

namespace App\Controller;
use \App\Helper\View;

class UserController {

    public function login() {
        View::render('login.php', array(), 'Login - Faproman');
    }

    // show register form
    public function register() {
        View::render('register.php', array(), 'Register - Faproman');
    }

    // logout user
    public function logout() {}

    // create new user
    public function create() {
        $errorMsgs = array();}

    // login user
    public function authenticate() {}

}
Enter fullscreen mode Exit fullscreen mode

Open the app/controller/ProjectController.php file and create the ProjectController class:

<?php

namespace App\Controller;
use \App\Helper\View;

class ProjectController {

    // home page
    public function index() {
        View::render('home.php', array(), 'Home Page');
    }

    // edit page
    public function edit(string $id) {
        View::render('edit.php', array('projectId' => $id), 'Edit Project');
    }

    // create new user
    public function create() {}

    // update a project
    public function update() {}

    // delete a project
    public function delete() {}

}
Enter fullscreen mode Exit fullscreen mode

Notice how the View::render() method is used to render the view files.

Handle routes with controller classes

Update the public/index.php file as follows:

<?php
/**
 * Autoload classes
 */ 
require __DIR__ . '/../vendor/autoload.php';

use \App\Controller\ProjectController;
use \App\Controller\UserController;
use \App\Helper\View;

$router = new AltoRouter();

// routes
$router->addRoutes(array(
    array('GET','/', array(new ProjectController, 'index')),
    array('GET','/edit/[i:id]', array(new ProjectController, 'edit')),
    array('GET','/login', array(new UserController, 'login')),
    array('GET','/register', array(new UserController, 'register')),
    array('GET','/logout', array(new UserController, 'logout')),
    array('POST','/user/create', array(new UserController, 'create')),
    array('POST','/user/authenticate', array(new UserController, 'authenticate')),
    array('POST','/project/create', array(new ProjectController, 'create')),
    array('POST','/project/update', array(new ProjectController, 'update')),
    array('POST','/project/delete', array(new ProjectController, 'delete')),
));

/* Match the current request */
$match = $router->match();

// call closure or throw 404 status
if( is_array($match) && is_callable( $match['target'] ) ) {
    // call the action function and pass parameters
    call_user_func_array( $match['target'], $match['params'] ); 
} else {
    // no route was matched
    header("HTTP/1.0 404 Not Found");
    View::render404();
}
Enter fullscreen mode Exit fullscreen mode

Now routing will be properly handled. Whenever a request matches any of the routes specified, the corresponding controller method is called.

Update template and CSS files

Open each of the following files, and add the relevant code to each of them.

To define the header, edit temp/header.php:

  <!DOCTYPE html>
  <html>
  <head>
      <meta charset="utf-8">
      <meta http-equiv="X-UA-Compatible" content="IE=edge">
      <title><?php echo isset($pageTitle) ? $pageTitle : null; ?></title>
      <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet"
          integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
      <link rel="stylesheet" href="/assets/style.css" />
      <style>
        body {
          padding-top: 56px;
        }
      </style>
  </head>
  <body>
Enter fullscreen mode Exit fullscreen mode

Bootstrap's JavaScript file is added just before the closing </body> to make bootstrap components that require JavaScript to work properly.

To define the footer, edit temp/footer.php:

  <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>
  </body>
  </html>
Enter fullscreen mode Exit fullscreen mode

Bootstrap's JavaScript file is added just before the closing </body> to make bootstrap components that require JavaScript to work properly.

To define the navbar, edit temp/nav.php:

  <nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
      <div class="container-fluid px-4">
          <a class="navbar-brand" href="/">Faproman</a>
          <button class="navbar-toggler" type="button" data-bs-toggle="collapse"
              data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false"
              aria-label="Toggle navigation">
              <span class="navbar-toggler-icon"></span>
          </button>
          <div class="collapse navbar-collapse" id="navbarSupportedContent">
              <ul class="navbar-nav me-auto mb-2 mb-lg-0">
              </ul>
              <div class="d-flex align-items-center">
                  <?php if (isset($_SESSION['logged_in_user'])) { ?>
                      <span class="text-white me-3"><i class="bi bi-person-circle"></i> <?php echo $_SESSION['logged_in_user']->username ?></span>
                      <a href="/logout" class="btn btn-outline-light">Logout</a>
                  <?php } else { ?>
                      <a href="/login" class="btn btn-outline-light">Login</a>
                  <?php } ?>
              </div>
          </div>
      </div>
  </nav>
Enter fullscreen mode Exit fullscreen mode

To define the 404 page, edit temp/404.php:

  <div class="page-404">
      404 | Not Found
  </div>
Enter fullscreen mode Exit fullscreen mode

To import the font and icons, and add additional styling, edit public/assets/style.css:

  @import url('https://fonts.googleapis.com/css2?family=Nunito&display=swap');
  @import url("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.8.3/font/bootstrap-icons.css");

  body {
      font-family: Nunito, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif !important;  
  }

  .form-error-box {
      color: #a94442;
    background-color: #f2dede;
    border: 1px solid #ebccd1;
    border-radius: 4px;
    font-size: 11px;
    padding: 6px;
    margin: 10px 0;
    -webkit-border-radius: 4px;
    -moz-border-radius: 4px;
    -ms-border-radius: 4px;
    -o-border-radius: 4px;
  }

  .page-404 {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    width: 100vw;
    display: flex;
    justify-content: center;
    align-items: center;
    font-size: 35px;
  }
Enter fullscreen mode Exit fullscreen mode

Configure your GraphQL client

Now you'll create a Fauna class that will handle all interactions with your database. Open the app/lib/Fauna.php and paste the below:

<?php

namespace App\Lib;

use Dotenv\Dotenv;
use GraphQL\Client;

// load env variables to $_ENV super global
$dotenv = Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();

define('CLIENT_SECRET', $_ENV['SECRET_KEY']);
define('FAUNA_GRAPHQL_BASE_URL', 'https://graphql.fauna.com/graphql');

class Fauna {
    public static function getClient(): Client {
        return new Client(
            FAUNA_GRAPHQL_BASE_URL,
            ['Authorization' => 'Bearer ' . CLIENT_SECRET]
        );
    }

    public static function createNewUser(string $username, string $password, int $create_timestamp) {}

    public static function getUserByUsername(string $username) {}

    public static function createNewProject(string $userId, string $name, string $description, bool $completed) {}

    public static function getProjectsByUser(string $id) {}

    public static function updateExistingProject(string $projectId, string $name, string $description, bool $completed) {} 

    public static function deleteExistingProject(string $projectId) {}

    public static function getSingleProjectByUser(string $projectId) {}
Enter fullscreen mode Exit fullscreen mode

The $dotenv->load() method loads all environment variables from the .env file into $_ENV and $_SERVER superglobals. The keys from your Fauna database should be stored in a .env file in the root directory of faproman, which will make the keys available in the aforementioned superglobals.

The Fauna::getClient() method returns a new configured instance of your GraphQL client.

Creating a Fauna database access key

Fauna uses secrets like access keys to authenticate clients. Follow the below steps to create one.

Navigate to your database dashboard and click on [ Security ] from the sidebar.

New access key

Click on New key.

New key form

In the key creation form, select the current database as your database, Server as the role, and faproman_app as the key name.

Note that a new database comes with two roles by default: Admin and Server. You can also create custom roles with your desired privileges. The Server option is selected because all interaction with the database will be done via PHPβ€”a server-side language.

Click on Save to save your key. Copy the key's secret and save it somewhere safe. Additionally, paste the key's secret in your .env file in the root directory with the variable name SECRET_KEY.

Now your application is ready to make calls to Fauna.

Custom resolvers and user-defined functions (UDF)

Recall that in the schema for Faproman discussed earlier, the @resolver directive was used to specify user-defined functions that will be used as custom resolvers to resolve the queries or mutations. User-defined functions that @resolver directives specify have to be created for custom resolvers to work when the fields are queried.

User-defined functions can be created either through Fauna's built-in shell, or through the function creation form. They are defined by the functions provided by Fauna Query Language (FQL).

Before proceeding, run the following in your shell to create project_ower_idx and user_username_idx indexes.

CreateIndex({
  name: "project_owner_idx",
  source: Collection("Project"),
  terms: [{ field: ["data", "owner"] }],
  serialized: true
})

CreateIndex({
  name: "user_username_idx",
  source: Collection("User"),
  terms: [{ field: ["data", "username"] }],
  serialized: true
})
Enter fullscreen mode Exit fullscreen mode

Shell

Run the following commands to create the user-defined functions (custom resolvers) as defined in the schema of the application.

NB: If any of the functions below already exist, click on [ Function ] on your Fauna dashboard, click on that function and click on [ Delete ] below the function's definition to delete the function. Run the command in the shell to recreate the function.

  • findUserByUsername
  CreateFunction({
    name: "findUserByUsername",
    body: Query(
        Lambda(
          ["username"],
          Get(
            Select(
              ["data", 0],
              Paginate(Match(Index("user_username_idx"), Var("username")))
            )
          )
        )
      )
  })
Enter fullscreen mode Exit fullscreen mode
  • findProjectsByUserId
  CreateFunction({
    name: "findProjectsByUserId",
    body: Query(
        Lambda(
          ["owner_id"],
          Select(
            "data",
            Map(
              Paginate(
                Reverse(
                  Match(
                    Index("project_owner_by_user"),
                    Ref(Collection("User"), Var("owner_id"))
                  )
                )
              ),
              Lambda(["ref"], Get(Var("ref")))
            )
          )
        )
      )
  })
Enter fullscreen mode Exit fullscreen mode
  • createNewProject
  CreateFunction({
    name: "createNewProject",
    body: Query(
        Lambda(
          ["owner_id", "name", "description", "completed", "create_timestamp"],
          Create(Collection("Project"), {
            data: {
              name: Var("name"),
              description: Var("description"),
              completed: Var("completed"),
              create_timestamp: Var("create_timestamp"),
              owner: Ref(Collection("User"), Var("owner_id"))
            }
          })
        )
      )
  })
Enter fullscreen mode Exit fullscreen mode
  • createNewUser
  CreateFunction({
    name: "createNewUser",
    body: Query(
        Lambda(
          ["username", "password", "create_timestamp"],
          Create(Collection("User"), {
            data: {
              username: Var("username"),
              password: Var("password"),
              create_timestamp: Var("create_timestamp")
            }
          })
        )
      )
  })
Enter fullscreen mode Exit fullscreen mode
  • deleteSingleProject
  CreateFunction({
    name: "deleteSingleProject",
    body: Query(Lambda(["id"], Delete(Ref(Collection("Project"), Var("id")))))
  })
Enter fullscreen mode Exit fullscreen mode
  • updateSingleProject
  CreateFunction({
    name: "updateSingleProject",
    body: Query(
        Lambda(
          ["project_id", "name", "description", "completed", "create_timestamp"],
          Update(Ref(Collection("Project"), Var("project_id")), {
            data: {
              name: Var("name"),
              description: Var("description"),
              completed: Var("completed"),
              create_timestamp: Var("create_timestamp")
            }
          })
        )
      )
  })
Enter fullscreen mode Exit fullscreen mode

Querying data

In this subsection, you'll update your GraphQL client and controller classes with a defined method.

Update GraphQL client class

Since the custom resolvers are up, the methods of the Fauna class can now be updated. The Fauna class encapsulates all the queries that'll be run on the app, inside its methods.

Open the app/lib/Fauna.php file and update it to look like the below:

<?php

namespace App\Lib;

use Dotenv\Dotenv;
use Exception;
use GraphQL\Client;
use GraphQL\Query;
use GraphQL\Mutation;
use GraphQL\RawObject;

// load env variables to $_ENV super global
$dotenv = Dotenv::createImmutable(__DIR__ . '/../../');
$dotenv->load();

define('CLIENT_SECRET', $_ENV['SECRET_KEY']);
define('FAUNA_GRAPHQL_BASE_URL', 'https://graphql.fauna.com/graphql');

class Fauna {
    public static function getClient(): Client {
        return new Client(
            FAUNA_GRAPHQL_BASE_URL,
            ['Authorization' => 'Bearer ' . CLIENT_SECRET]
        );
    }

    public static function createNewUser(string $username, string $password, int $create_timestamp): string | object {
        try {
            $mutation = (new Mutation('createUser'))
                ->setArguments(['data' => new RawObject('{username: "' . $username . '", password: "' . $password . '", create_timestamp: ' . $create_timestamp . '}')])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'username',
                        'password',
                        'create_timestamp'
                    ]
                );
            $result = self::getClient()->runQuery($mutation);
            return $result->getData()->createUser;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function getUserByUsername(string $username): string | object {
        try {
            $gql = (new Query('findUserByUsername'))
                ->setArguments(['username' => $username])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'username',
                        'password',
                        'create_timestamp'
                    ]
                );
            $result = self::getClient()->runQuery($gql);
            return $result->getData()->findUserByUsername;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function createNewProject(string $userId, string $name, string $description, bool $completed): string | object {
        try {
            $mutation = (new Mutation('createNewProject'))
                ->setArguments(['name' => $name, 'description' => $description, 'completed' => $completed, 'owner_id' => $userId])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'name',
                        'description',
                        'completed'
                    ]
                );
            $result = self::getClient()->runQuery($mutation);
            return $result->getData()->createNewProject;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function getProjectsByUser(string $id): string | array {
        try {
            $gql = (new Query('findProjectsByUserId'))
                ->setArguments(['owner_id' => $id])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'name',
                        'description',
                        'completed',
                        'create_timestamp'
                    ]
                );
            $result = self::getClient()->runQuery($gql);
            return $result->getData()->findProjectsByUserId;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function updateExistingProject(string $projectId, string $name, string $description, bool $completed): string | object {
        try {
            $mutation = (new Mutation('updateSingleProject'))
                ->setArguments(['project_id' => $projectId, 'name' => $name, 'description' => $description, 'completed' => $completed])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'name',
                        'description',
                        'completed'
                    ]
                );
            $result = self::getClient()->runQuery($mutation);
            return $result->getData()->updateSingleProject;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function deleteExistingProject(string $projectId): string | object {
        try {
            $mutation = (new Mutation('deleteSingleProject'))
                ->setArguments(['id' => $projectId])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'name',
                        'description',
                        'completed'
                    ]
                );
            $result = self::getClient()->runQuery($mutation);
            return $result->getData()->deleteSingleProject;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }

    public static function getSingleProjectByUser(string $projectId): string | object | null {
        try {
            $gql = (new Query('findProjectByID'))
                ->setArguments(['id' => $projectId])
                ->setSelectionSet(
                    [
                        '_id',
                        '_ts',
                        'name',
                        'description',
                        'completed',
                        'create_timestamp'
                    ]
                );
            $result = self::getClient()->runQuery($gql);
            return $result->getData()->findProjectByID;
        } catch(Exception $e) {
            return $e->getMessage();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Here, the Query and Mutation classes take in the name of the query as an argument, and the setArguments method takes in an associative array of query arguments and their respective values. The setSelectionSet method specifies the set of data you want from the GraphQL endpoint. The GraphQL client (getClient()) provides the runQuery and getData methods to run a query and get the result, respectively.

Update controller classes

Update the two controller classes, since the Fauna class is now all set.

Open the app/controller/UserController.php file and update it as shown below:

<?php

namespace App\Controller;
use \App\Helper\View;
use \App\Lib\Fauna;

class UserController {
    // default time to live
    private $ttl = 30;

    // show login form
    public function login() {
        View::render('login.php', array(), 'Login - Faproman');
    }

    // show register form
    public function register() {
        View::render('register.php', array(), 'Register - Faproman');
    }

    // logout user
    public function logout() {
        // clear session
        session_unset();   
        session_destroy();
        header('Location: /login');
    }

    // create new user
    public function create() {
        $errorMsgs = array();
        if (empty($_POST['username'])) array_push($errorMsgs, 'Username is required');
        if (!preg_match('/^[A-Z0-9]*$/i', $_POST['username'])) array_push($errorMsgs, 'Username can only contain alphanumeric characters');
        if (empty($_POST['password1'])) array_push($errorMsgs, 'Password is required');
        if ($_POST['password1'] != $_POST['password2']) array_push($errorMsgs, 'Passwords must be the same');

        if (!empty($errorMsgs)) {
            $_SESSION['register_errors'] = $errorMsgs;
            return header('Location: /register');
        } 

        $newUser = Fauna::createNewUser(strtolower($_POST['username']), password_hash($_POST['password1'], PASSWORD_DEFAULT), time());

        if (gettype($newUser) == 'string') {
            preg_match('/not unique/i', $newUser) ? array_push($errorMsgs, 'Username is taken, use another') : array_push($errorMsgs, 'Something went wrong');
            $_SESSION['register_errors'] = $errorMsgs;
            return header('Location: /register');
        }

        $_SESSION['logged_in_user'] = $newUser;
        $_SESSION['ttl'] = $this->ttl;

        return header('Location: /');        
    }

    // login user
    public function authenticate() {
        $errorMsgs = array();
        if (empty($_POST['username'])) array_push($errorMsgs, 'Username is required');
        if (empty($_POST['password'])) array_push($errorMsgs, 'Password is required');
        if (!preg_match('/^[A-Z0-9]*$/i', $_POST['username'])) array_push($errorMsgs, 'Username or password is incorrect');

        if (!empty($errorMsgs)) {
            $_SESSION['login_errors'] = $errorMsgs;
            return header('Location: /login');
        } 

        $user = Fauna::getUserByUsername($_POST['username']);

        // verify that user exist
        if (gettype($user) == 'string') {
            preg_match('/not found/i', $user) ? array_push($errorMsgs, 'Username or password is incorrect') : array_push($errorMsgs, 'Something went wrong');
            $_SESSION['login_errors'] = $errorMsgs;
            return header('Location: /login');
        }

        // verify that password is correct
        if (!password_verify($_POST['password'], $user->password)) {
            array_push($errorMsgs, 'Username or password is incorrect');
            $_SESSION['login_errors'] = $errorMsgs;
            return header('Location: /login');
        }

        $_SESSION['logged_in_user'] = $user;
        $_SESSION['ttl'] = $this->ttl;

        return header('Location: /'); 
    }

}
Enter fullscreen mode Exit fullscreen mode

Open the app/controller/ProjectController.php file and update it to match the following:

<?php

namespace App\Controller;
use \App\Helper\View;
use \App\Lib\Fauna;

class ProjectController {

    // home page
    public function index() {
        View::render('home.php', array(), 'Home Page');
    }

    // edit page
    public function edit(string $id) {
        View::render('edit.php', array('projectId' => $id), 'Edit Project');
    }

    // create new user
    public function create() {
        $errorMsgs = array();
        if (empty($_POST['name'])) array_push($errorMsgs, 'Project name is required');
        if (empty($_POST['description'])) array_push($errorMsgs, 'Description is required');

        if (!empty($errorMsgs)) {
            $_SESSION['project_errors'] = $errorMsgs;
            return header('Location: /');
        }

        $userId = $_SESSION['logged_in_user']->_id;
        $name = htmlentities($_POST['name'], ENT_QUOTES, 'UTF-8');
        $description = htmlentities($_POST['description'], ENT_QUOTES, 'UTF-8');
        $completed = isset($_POST['completed']);

        $newProject = Fauna::createNewProject($userId, $name, $description, $completed);

        if (gettype($newProject) == 'string') {
            array_push($errorMsgs, 'Something went wrong');
            $_SESSION['project_errors'] = $errorMsgs;
            return header('Location: /register');
        }

        return header('Location: /');        
    }

    // update a project
    public function update() {
        $errorMsgs = array();
        if (empty($_POST['name'])) array_push($errorMsgs, 'Project name is required');
        if (empty($_POST['description'])) array_push($errorMsgs, 'Description is required');

        $projectId = htmlentities($_POST['project_id'], ENT_QUOTES, 'UTF-8');

        if (!empty($errorMsgs)) {
            $_SESSION['project_errors'] = $errorMsgs;
            return header('Location: /edit/'.$projectId);
        }

        $name = htmlentities($_POST['name'], ENT_QUOTES, 'UTF-8');
        $description = htmlentities($_POST['description'], ENT_QUOTES, 'UTF-8');
        $completed = isset($_POST['completed']);

        $newProject = Fauna::updateExistingProject($projectId, $name, $description, $completed);

        if (gettype($newProject) == 'string') {
            array_push($errorMsgs, 'Something went wrong');
            $_SESSION['project_errors'] = $errorMsgs;
            return header('Location: /');
        }

        return header('Location: /');        
    }

    // delete a project
    public function delete() {
        $errorMsgs = array();     
        $projectId = htmlentities($_POST['project_id'], ENT_QUOTES, 'UTF-8');

        $newProject = Fauna::deleteExistingProject($projectId);

        if (gettype($newProject) == 'string') {
            array_push($errorMsgs, 'Something went wrong');
            $_SESSION['project_errors'] = $errorMsgs;
            return header('Location: /');
        }

        return header('Location: /');        
    }

}
Enter fullscreen mode Exit fullscreen mode

Authentication and session management

In this subsection, you'll set up user authentication for the application. You'll also manage sessions using PHP's built-in session feature.

Start session

The session_start() function is used to create or resume a session. It must be called at the top of every file in which you want a session to be present. Calling the function in the index.php file will make a session available everywhere in the application, since all requests are redirected to it.

Open the public/index.php file and call the session_start() method at the top:

<?php
session_start();
/**
 * Autoload classes
 */ 
require __DIR__ . '/../vendor/autoload.php';

// if user is logged in but inactive for given TTL time then logout user
if (
    isset($_SESSION['logged_in_user']) && 
    isset($_SESSION['ttl']) && 
    isset($_SESSION['last_activity']) && 
    (time() - $_SESSION['last_activity'] > ($_SESSION['ttl'] * 60))
) {
    session_unset();
    session_destroy();
    header('Location: /login');    
} 

// record current time
$_SESSION['last_activity'] = time();
//..
//..
Enter fullscreen mode Exit fullscreen mode

Sign up

Open the view/register.php file and add the following:

<?php 
if (isset($_SESSION['register_errors'])) {
    $regErrors = $_SESSION['register_errors'];
    unset($_SESSION['register_errors']);
}
if (isset($_SESSION['logged_in_user'])) {
    header('Location: /');
}
?>

<div class="container my-5 text-center">
    <h2>Sign Up to Faproman</h1>
        <p>Sign up a new Faproman account</p>

        <form class="text-start mx-auto mt-3" method="post" action="/user/create" style="max-width: 400px;">
            <?php
            if (isset($regErrors) && !empty($regErrors)) {
            ?>
                <div class="form-error-box">
                    <?php
                    foreach ($regErrors as $value) {
                        echo $value . '<br>';
                    } 
                    ?>
                </div>
            <?php
            }
            ?>
            <div class="mb-3">
                <label for="username" class="form-label">Username</label>
                <input type="text" required class="form-control" name="username" id="username">
            </div>
            <div class="mb-3">
                <label for="password1" class="form-label">Password</label>
                <input type="password" required class="form-control" name="password1" id="password1">
            </div>
            <div class="mb-2">
                <label for="password2" class="form-label">Repeat Password</label>
                <input type="password" required class="form-control" name="password2" id="password2">
            </div>
            <div class="form-text text-end mb-4">Have an account? <a href="/login">Login</a></div>
            <button type="submit" class="btn text-white bg-dark d-block w-100">Sign Up</button>
        </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Navigate to https://faproman.test/register. Your screen should look like the image below, prompting you to create an account.

Register page

The action attribute of the sign-up form points to /user/create, which is handled by the create method of the UserController class. This method also handles validation and password hashing before using the Fauna::createNewUser() method to write to the Fauna database.

When the user is successfully created, the user is logged into the system by setting the $_SESSION['logged_in_user'] superglobal item to the user object returned from the GraphQL query. After that, the user is redirected to the home page.

Additionally, a time to live (TTL) of thirty minutes is set via $_SESSION['ttl']. This will log the user out of the system if there's no interaction for more than thirty minutes.

Log in

Open the file view/login.php and add the following:

<?php 
if (isset($_SESSION['login_errors'])) {
    $loginErrors = $_SESSION['login_errors'];
    unset($_SESSION['login_errors']);
}
if (isset($_SESSION['logged_in_user'])) {
    header('Location: /');
}
?>

<div class="container my-5 text-center">
    <h2>Login to Faproman</h1>
        <p>You must login to manage your projects</p>
        <form method="post" action="/user/authenticate" class="text-start mx-auto mt-3" style="max-width: 400px;">
            <?php
            if (isset($loginErrors) && !empty($loginErrors)) {
            ?>
                <div class="form-error-box">
                    <?php
                    foreach ($loginErrors as $value) {
                        echo $value . '<br>';
                    } 
                    ?>
                </div>
            <?php
            }
            ?>
            <div class="mb-3">
                <label for="username" class="form-label">Username</label>
                <input type="text" required class="form-control" name="username" id="username">
            </div>
            <div class="mb-2">
                <label for="password" class="form-label">Password</label>
                <input type="password" required class="form-control" name="password" id="password">
            </div>
            <div class="form-text text-end mb-4">New? <a href="/register">Sign Up</a></div>
            <button type="submit" class="btn text-white bg-dark d-block w-100">Login</button>
        </form>
</div>
Enter fullscreen mode Exit fullscreen mode

Navigate to https://faproman.test/login. Your screen should now look like the image below, prompting users to log in to access their projects.

Login page

The action attribute of the login form points to /user/authenticate, which is handled by the authenticate method of the UserController class. The method also fetches the user from Fauna using the Fauna::getUserByUsername() method.

If the user is found and the password is verified, the user is logged into the system by setting the $_SESSION['logged_in_user'] superglobal item to the user object returned from the GraphQL query. The user is then redirected to the home page.

As before, a time to live (TTL) of thirty minutes is set via $_SESSION['ttl']. This will log the user out of the system if there's no interaction for more than thirty minutes.

Forms and updates

In this subsection, you'll set up the forms that will enable users to create, edit, and update their projects.

Home page

The home page consists of a form to add new projects, a form to delete projects, and an accordion menu to display existing projects. Projects by a user are obtained using the Fauna::getProjectsByUser() method.

Open the view/home.php file and add the below:

<?php
use \App\Lib\Fauna;

if (!isset($_SESSION['logged_in_user'])) {
    header('Location: /login');
}
$loggedInUser = $_SESSION['logged_in_user'];

if (isset($_SESSION['project_errors'])) {
    $projectErrors = $_SESSION['project_errors'];
    unset($_SESSION['project_errors']);
}

$userProjects = Fauna::getProjectsByUser($loggedInUser->_id);
?>

<div class="container my-3">
    <h2 class="mt-5">Welcome @<?php echo $loggedInUser->username ?></h2>
    <p>Manage your projects here.</p>
    <form method="post" action="/project/create" class="text-start mt-3">
        <?php
        if (isset($projectErrors) && !empty($projectErrors)) {
        ?>
        <div class="form-error-box">
            <?php
                foreach ($projectErrors as $value) {
                    echo $value . '<br>';
                } 
                ?>
        </div>
        <?php
        }
        ?>
        <div class="form-floating mb-3">
            <input type="text" required class="form-control" id="floatingInput" name="name"
                placeholder="Name of project">
            <label for="name">Project Name</label>
        </div>
        <div class="form-floating">
            <textarea required class="form-control" placeholder="Enter project description here..." name="description"
                id="description" style="height: 100px"></textarea>
            <label for="description">Description</label>
        </div>
        <div class="form-check mt-2">
            <input class="form-check-input" type="checkbox" name="completed" id="completed">
            <label class="form-check-label" for="completed">
                Completed
            </label>
        </div>
        <button type="submit" class="btn text-white bg-dark mt-3">Add Project</button>
    </form>

    <h4 class="mt-5">All Your Projects</h4>
    <p><?php echo "No of projects: " . count($userProjects); ?></p>
    <div class="accordion my-3 mb-5" id="accordionExample">
        <?php 
        if (gettype($userProjects) == "array"):
            for ($i = 0; $i < count($userProjects); $i++): 
            $project = $userProjects[$i];
        ?>
        <div class="accordion-item">
            <h2 class="accordion-header"  id="headingOne<?php echo $i; ?>">
                <button class="accordion-button <?php echo $i != 0 ? "collapsed" : null; ?>" type="button" data-bs-toggle="collapse"
                    data-bs-target="#collapse<?php echo $i; ?>" aria-expanded="true"
                    aria-controls="collapse<?php echo $i; ?>">
                    <?php echo $project->name; ?>
                </button>
            </h2>
            <div id="collapse<?php echo $i; ?>"
                class="accordion-collapse collapse <?php echo $i == 0 ? "show" : null; ?>"
                aria-labelledby="headingOne<?php echo $i; ?>"
                data-bs-parent="#accordionExample">
                <div class="accordion-body">
                    <?php echo $project->description; ?>
                </div>
                <div class="border m-2 p-2 d-flex justify-content-between align-items-center">
                    <div>Completed: <strong><?php echo $project->completed ? "Yes" : "No" ?></strong></div>
                    <div class="d-flex">
                        <a href="/edit/<?php echo $project->_id ?>" class="btn btn-outline-dark border-0 py-0 px-1 me-1"><i class="bi bi-pencil-square"></i></a>
                        <form action="/project/delete" method="post">
                            <input type="hidden" name="project_id" value="<?php echo $project->_id ?>">
                            <button type="submit" class="btn btn-outline-danger border-0 py-0 px-1"><i class="bi bi-trash"></i></button>
                        </form>
                    </div>
                </div>
            </div>
        </div>
        <?php
            endfor;
        endif;
        ?>
    </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Navigate to https://faproman.test. Log in if you're not logged in, and create a new project via the form. Your screen should look like the one below.

Home page

The action attribute of the project creation form points to /project/create, which is handled by the create method of the ProjectController class. The method also handles validation before using the Fauna::createNewProject() method to write to the Fauna database.

The action attribute of the project deletion form points to /project/delete, which is handled by the delete method of the ProjectController class, which uses the Fauna::deleteExistingProject() to delete a project.

Update project

The edit page consists of a form to update projects.

Open the file view/edit.php and add the following:

<?php
use \App\Lib\Fauna;

if (!isset($_SESSION['logged_in_user'])) {
    header('Location: /login');
}
$loggedInUser = $_SESSION['logged_in_user'];

if (isset($_SESSION['project_errors'])) {
    $projectErrors = $_SESSION['project_errors'];
    unset($_SESSION['project_errors']);
}

$project = Fauna::getSingleProjectByUser($projectId);
?>

<div class="container my-3">
    <?php if (gettype($project) != 'object'): ?>
    <h2 class="mt-5">Project Not Found</h2>
    <?php else: ?>
    <h2 class="mt-5">Update Project</h2>
    <p>Update your project here.</p>
    <form method="post" action="/project/update" class="text-start mt-3">
        <?php
        if (isset($projectErrors) && !empty($projectErrors)) {
        ?>
        <div class="form-error-box">
            <?php
                foreach ($projectErrors as $value) {
                    echo $value . '<br>';
                } 
                ?>
        </div>
        <?php
        }
        ?>
        <div class="form-floating mb-3">
            <input type="text" required class="form-control" id="floatingInput" name="name"
                placeholder="Name of project" value="<?php echo $project->name ?>">
            <label for="name">Project Name</label>
        </div>
        <div class="form-floating">
            <textarea required class="form-control" placeholder="Enter project description here..." name="description"
                id="description" style="height: 100px"><?php echo $project->description ?></textarea>
            <label for="description">Description</label>
        </div>
        <div class="form-check mt-2">
            <input class="form-check-input" type="checkbox" name="completed" id="completed" <?php echo $project?->completed ? "checked" : null ?>>
            <label class="form-check-label" for="completed">
                Completed
            </label>
        </div>
        <input type="hidden"  name="project_id" value="<?php echo $project->_id ?>">
        <button type="submit" class="btn text-white bg-dark mt-3">Edit Project</button>
        <a href="/" class="btn btn-outline-dark mt-3 ms-2">Cancel</a>
    </form>
    <?php endif; ?>
</div>
Enter fullscreen mode Exit fullscreen mode

Navigate once again to https://faproman.test. Log in if you're not logged in, and create a new project via the form if one doesn't yet exist. Click on the edit button in the accordion. Your screen should now look like the one below:

Edit page

The action attribute of the project creation form points to /project/update, which is handled by the update method of the ProjectController class. The method also handles validation before using the Fauna::updateExistingProject() method to update the document in the Fauna database.

Conclusion

In this article, you learned how GraphQL works with Fauna, how to create and upload a GraphQL schema, what happens when a schema is uploaded to Fauna, and the relationship between schema types. You were taken through the process of setting up a virtual host on your local machine, configuring a GraphQL client, and creating a secret key for it. You also learned about custom resolvers and the roles they play in Fauna, and followed detailed steps on how to build a PHP application from start to finish using GraphQL and Fauna.

Fauna is a serverless, flexible, developer-friendly, transactional database and data API for modern applications. Fauna offers native GraphQL support with transactions, custom logic, and access control. Fauna users can effortlessly build GraphQL services to fetch data from its databases.

Top comments (0)