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:
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:
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).
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
}
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
}
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")
}
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:
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
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
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>
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>
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";
.
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.
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/"
}
}
}
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
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]
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"
}
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');
}
}
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() {}
}
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() {}
}
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();
}
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>
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>
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>
To define the 404 page, edit temp/404.php
:
<div class="page-404">
404 | Not Found
</div>
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;
}
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) {}
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.
Click on New key.
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
})
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")))
)
)
)
)
})
-
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")))
)
)
)
)
})
-
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"))
}
})
)
)
})
-
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")
}
})
)
)
})
-
deleteSingleProject
CreateFunction({
name: "deleteSingleProject",
body: Query(Lambda(["id"], Delete(Ref(Collection("Project"), Var("id")))))
})
-
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")
}
})
)
)
})
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();
}
}
}
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: /');
}
}
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: /');
}
}
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();
//..
//..
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>
Navigate to https://faproman.test/register
. Your screen should look like the image below, prompting you to create an account.
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>
Navigate to https://faproman.test/login
. Your screen should now look like the image below, prompting users to log in to access their projects.
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>
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.
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>
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:
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)